Kunde löschen schaltet die Funktion zeiten wie den Terminplaner ab (Grund kunde_geloescht) und löscht Zeiten, Erinnerungsmerker und Antwortspeicher sofort; Fehler halten die Kundenlöschung nicht auf. Die Löschfristen löschen die Zeiten vor dem Abschalten und zählen sie im Bericht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
15 KiB
JavaScript
301 lines
15 KiB
JavaScript
import { rm } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { config } from '../config.js'
|
|
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js'
|
|
import { deleteNewsImage } from './newsImages.js'
|
|
import { disable } from './siteFeatures.js'
|
|
import { getStripe } from './stripeBilling.js'
|
|
import { filesOf, STATUS } from './takeoverCredits.js'
|
|
|
|
/**
|
|
* Loeschfristen (Datenschutzerklaerung Abschnitt 7 und 9, Rechtsrunde Entscheidung 11):
|
|
* - Abo-Daten: "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet."
|
|
* Ein Kunde ist faellig, wenn alle seine Abos beendet sind und seit dem Ende des Kalenderjahres seines
|
|
* letzten Abo-Endes drei Jahre vergangen sind (Ende 15.03.2022 -> faellig am 01.01.2026). Geloescht werden
|
|
* die Dokumente in subscriptions und contractEvents sowie die Zustimmungsangaben in den Stripe-Metadaten
|
|
* (F41b). Rechnungen bleiben (Abschnitt 9).
|
|
* - Uebernahme-Antraege: Nachweise abgelehnter Antraege loescht schon die taegliche Bereinigung
|
|
* (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die
|
|
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
|
|
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
|
|
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
|
|
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
|
|
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
|
|
* - Oeffnungszeiten live: Zeigt der Plan Funktionen 'zeiten' (plan.news[].zeitenIds, Teilmenge von featureIds), loescht der Lauf vor dem
|
|
* 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.
|
|
* 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
|
|
* Liste abgeschnitten (>= LIST_LIMIT), bricht der Lauf ab, bevor etwas geplant oder geloescht wird.
|
|
*/
|
|
|
|
const SUBSCRIPTIONS = () => config.collections.subscriptions
|
|
const EVENTS = () => config.collections.contractEvents
|
|
const TAKEOVER = () => config.collections.takeoverCredits
|
|
const NEWS_POSTS = () => config.collections.newsPosts
|
|
const FEATURES = () => config.collections.portalFeatures
|
|
|
|
export const RETENTION_YEARS = 3
|
|
export const RECEIPT_YEARS = 8
|
|
const LIST_LIMIT = 5000
|
|
|
|
/** Alle Zustimmungs-/Nachweisangaben, die das Portal in die Stripe-Metadaten des Abos schreibt. */
|
|
export const CONSENT_METADATA_KEYS = [
|
|
'termsVersion',
|
|
'termsAcceptedAt',
|
|
'earlyStartConsentAt',
|
|
'startChoice',
|
|
'consentIp',
|
|
'consentUserAgent',
|
|
'consentTextVersion',
|
|
'switchedAt',
|
|
'switchedFrom',
|
|
]
|
|
|
|
const berlinYear = (dateLike) =>
|
|
Number(new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(new Date(dateLike)))
|
|
|
|
const validDate = (value) => (value && !Number.isNaN(new Date(value).getTime()) ? value : null)
|
|
|
|
/** Ende eines Abos (ISO) oder null, wenn es nicht beendet ist. */
|
|
function endOf(doc) {
|
|
// Stripe hat das Abo nie aktiviert und verfallen lassen: zaehlt als beendet
|
|
if (doc.status === 'incomplete_expired' && !validDate(doc.endedAt)) {
|
|
return validDate(doc.canceledAt) || validDate(doc.$updatedAt) || validDate(doc.startedAt)
|
|
}
|
|
if (!validDate(doc.endedAt) && doc.status !== 'canceled') return null
|
|
return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd)
|
|
}
|
|
|
|
/**
|
|
* @param {Date} [now]
|
|
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
|
|
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
|
|
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
|
|
*/
|
|
export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } = {}) {
|
|
const currentYear = berlinYear(now)
|
|
const list = async (collection, queries = []) => {
|
|
const docs = await listDocuments(collection, [...queries, Query.limit(limit)])
|
|
if (docs.length >= limit) throw new Error(`Liste abgeschnitten, Paginierung nötig (${collection}, ${docs.length} Einträge)`)
|
|
return docs
|
|
}
|
|
const subs = await list(SUBSCRIPTIONS())
|
|
const byCustomer = new Map()
|
|
for (const doc of subs) {
|
|
if (!doc.customerId) continue
|
|
if (!byCustomer.has(doc.customerId)) byCustomer.set(doc.customerId, [])
|
|
byCustomer.get(doc.customerId).push(doc)
|
|
}
|
|
|
|
const customers = []
|
|
const pending = []
|
|
const skipped = []
|
|
for (const [customerId, docs] of byCustomer) {
|
|
const incomplete = docs.filter((d) => d.status === 'incomplete')
|
|
const others = docs.filter((d) => d.status !== 'incomplete')
|
|
if (incomplete.length && others.every((d) => endOf(d))) {
|
|
pending.push({
|
|
customerId,
|
|
subscriptionIds: incomplete.map((d) => d.$id),
|
|
reason: 'Abo im Status incomplete hält Löschung auf (Stripe klären: wird es aktiv oder verfällt es?)',
|
|
})
|
|
}
|
|
const ends = docs.map(endOf)
|
|
// ein laufendes Abo (oder ein beendetes ohne erkennbares Datum) haelt alle Daten des Kunden
|
|
if (ends.some((end) => !end)) continue
|
|
const lastEndAt = ends.reduce((a, b) => (new Date(a) > new Date(b) ? a : b))
|
|
if (currentYear < berlinYear(lastEndAt) + RETENTION_YEARS + 1) continue
|
|
const events = await list(EVENTS(), [Query.equal('customerId', customerId)])
|
|
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
|
|
}
|
|
|
|
const news = []
|
|
for (const { customerId } of customers) {
|
|
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
|
|
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
|
|
if (!posts.length && !features.length) continue
|
|
news.push({
|
|
customerId,
|
|
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
|
|
featureIds: features.map((f) => f.$id),
|
|
zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id),
|
|
})
|
|
}
|
|
|
|
const takeover = []
|
|
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
|
|
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
|
|
for (const doc of rejected) {
|
|
if (!filesOf(doc).length || !validDate(doc.decidedAt) || new Date(doc.decidedAt).getTime() > rejectedCutoff) continue
|
|
takeover.push({
|
|
id: doc.$id,
|
|
action: 'melden',
|
|
reason: `abgelehnt am ${doc.decidedAt.slice(0, 10)}, Nachweise noch vorhanden (die tägliche Bereinigung löscht sie nach sechs Monaten; Logs und Upload-Ordner prüfen)`,
|
|
})
|
|
}
|
|
const approved = await list(TAKEOVER(), [Query.equal('status', STATUS.approved)])
|
|
for (const doc of approved) {
|
|
const decided = validDate(doc.decidedAt)
|
|
if (!decided) {
|
|
skipped.push({ id: doc.$id, reason: 'genehmigt, aber ohne Entscheidungsdatum (decidedAt); Frist nicht berechenbar, von Hand prüfen' })
|
|
continue
|
|
}
|
|
if (currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue
|
|
takeover.push({
|
|
id: doc.$id,
|
|
action: 'loeschen',
|
|
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
|
|
})
|
|
}
|
|
return { customers, news, takeover, pending, skipped }
|
|
}
|
|
|
|
const isGone = (err) => err?.status === 404
|
|
|
|
async function deleteQuietly(collection, id) {
|
|
try {
|
|
await deleteDocument(collection, id)
|
|
return true
|
|
} catch (err) {
|
|
if (isGone(err)) return false
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
|
|
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
|
* 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, zeiten: number },
|
|
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
|
* failed: { what: string, id: string, reason: string }[] }>}
|
|
*/
|
|
export async function applyDeletions(plan) {
|
|
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, zeiten: 0 }
|
|
const manual = []
|
|
const failed = []
|
|
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
|
|
const attempt = async (what, id, fn) => {
|
|
try {
|
|
return await fn()
|
|
} catch (err) {
|
|
failed.push({ what, id, reason: err.message })
|
|
return false
|
|
}
|
|
}
|
|
|
|
for (const customer of plan.customers || []) {
|
|
for (const id of customer.subscriptionIds) {
|
|
try {
|
|
await getStripe().subscriptions.update(id, { metadata: clear })
|
|
deleted.stripeCleared += 1
|
|
} catch (err) {
|
|
manual.push({
|
|
customerId: customer.customerId,
|
|
subscriptionId: id,
|
|
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
|
|
})
|
|
}
|
|
}
|
|
for (const id of customer.eventIds) {
|
|
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
|
|
}
|
|
for (const id of customer.subscriptionIds) {
|
|
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
|
|
}
|
|
}
|
|
|
|
for (const entry of plan.news || []) {
|
|
for (const post of entry.posts) {
|
|
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
|
|
if (post.imageId) {
|
|
const removed = await attempt('newsImages', post.imageId, async () => {
|
|
await deleteNewsImage(post.projectId, post.imageId)
|
|
return true
|
|
})
|
|
if (!removed) continue
|
|
deleted.newsImages += 1
|
|
}
|
|
if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1
|
|
}
|
|
// Oeffnungszeiten: erst die Daten (Dokumente, Merker, Speicher), dann das Abschalten; scheitert das Loeschen, bleiben diese Funktionen
|
|
// unangetastet und der Fehler steht im Bericht (wie beim Bild vor dem News-Beitrag)
|
|
const zeitenIds = new Set(entry.zeitenIds || [])
|
|
let zeitenWeg = true
|
|
if (zeitenIds.size) {
|
|
zeitenWeg = await attempt('oeffnungszeiten', entry.customerId, async () => {
|
|
const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js')
|
|
deleted.zeiten += await loescheZeitenVonKunde(entry.customerId)
|
|
return true
|
|
})
|
|
}
|
|
for (const id of entry.featureIds) {
|
|
if (!zeitenWeg && zeitenIds.has(id)) continue
|
|
const off = await attempt('portalFeatures', id, async () => {
|
|
// disable() meldet bei Analyse auch den Sammeldienst ab (best effort)
|
|
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
|
return true
|
|
})
|
|
if (off) deleted.features += 1
|
|
}
|
|
}
|
|
|
|
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
|
|
const removed = await attempt('takeoverCredits', item.id, async () => {
|
|
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
|
return true
|
|
})
|
|
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
|
|
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
|
|
}
|
|
return { deleted, manual, failed }
|
|
}
|
|
|
|
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
|
|
|
|
/** Bericht als Text (Konsole und T14); result nur nach dem Loeschen. */
|
|
export function formatReport(plan, result = null) {
|
|
const lines = []
|
|
if (!plan.customers.length) lines.push('Kunden: nichts fällig.')
|
|
for (const c of plan.customers) {
|
|
lines.push(
|
|
`Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)`
|
|
)
|
|
}
|
|
for (const n of plan.news || []) {
|
|
const images = n.posts.filter((p) => p.imageId).length
|
|
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${zeiten}`)
|
|
}
|
|
const del = plan.takeover.filter((t) => t.action === 'loeschen')
|
|
const report = plan.takeover.filter((t) => t.action === 'melden')
|
|
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
|
|
if (report.length) lines.push('', 'Zur Kenntnis (nicht von diesem Lauf gelöscht):', ...report.map((t) => ` ${t.id}: ${t.reason}`))
|
|
if (plan.pending?.length) {
|
|
lines.push('', 'Hält Löschung auf:', ...plan.pending.map((p) => ` Kunde ${p.customerId}, Abo ${p.subscriptionIds.join(', ')}: ${p.reason}`))
|
|
}
|
|
if (plan.skipped?.length) {
|
|
lines.push('', 'Übersprungen (von Hand prüfen):', ...plan.skipped.map((t) => ` ${t.id}: ${t.reason}`))
|
|
}
|
|
if (result) {
|
|
const d = result.deleted
|
|
lines.push(
|
|
'',
|
|
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
|
|
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
|
|
)
|
|
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}`))
|
|
}
|
|
if (result.manual.length) {
|
|
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
|
|
}
|
|
}
|
|
return lines.join('\n')
|
|
}
|