TP2 Abschlussprüfung: Löschung (IP/Browser auf portalFeatures, Erstattungsspur, K22-Datum)

- markOrdered speichert IP und Browser der Bestellung nicht mehr am Funktionsdokument (nur im Nachweis featureConsents, dort mit
  Löschfrist); Altwerte werden bei einer neuen Bestellung geleert
- Aufbewahrungslauf (retention): consentIp, consentUserAgent, consentText, consentAt werden an allen Funktionsdokumenten eines fälligen
  Kunden geleert, auch an abgeschalteten; Fehlschlag hält den Kunden wie die übrigen abhängigen Daten
- Einmal-Skript scripts/feature-consent-felder-leeren.mjs (Standard Probelauf, --apply leert nur, wo featureConsents die Angabe trägt)
- 30-Tage-Reset (jobs.js): auch eine bezahlte Einrichtung mit Grund abo_ende_vor_einrichtung und noch nicht gemeldetem T19 wird nicht
  zurückgesetzt, solange zahlungMeldung nicht gemeldet ist (die Spur der Erstattung bleibt)
- K22: enddatum ist das Abo-Ende (lage.endedAt), sonst der Tag des Abschaltens; loeschdatum bleibt 30 Tage nach dem Abschalten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 06:26:33 +00:00
1 parent b974b3a3ec
commit 2fc48df915
13 files changed
+425 -22

No files matched your search

+36 -8
View File
@@ -1,7 +1,7 @@
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 { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { deleteNewsImage } from './newsImages.js'
import { disable } from './siteFeatures.js'
import { getStripe } from './stripeBilling.js'
@@ -25,6 +25,9 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet") loescht derselbe Lauf fuer faellige
* Kunden mit (plan.news[].consentIds). Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig; ihre Nachweise bleiben, wie ihre
* Beitraege, ausserhalb dieser Frist.
* - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument
* (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren
* Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey).
* 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
@@ -38,6 +41,11 @@ const NEWS_POSTS = () => config.collections.newsPosts
const FEATURES = () => config.collections.portalFeatures
const CONSENTS = () => config.collections.featureConsents
/** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */
const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt']
const BLANK_FEATURE_FIELDS = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }
const hasConsentFields = (doc) => FEATURE_CONSENT_FIELDS.some((key) => doc[key])
export const RETENTION_YEARS = 3
export const RECEIPT_YEARS = 8
const LIST_LIMIT = 5000
@@ -73,7 +81,8 @@ function endOf(doc) {
/**
* @param {Date} [now]
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
* news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[] }[],
* news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[],
* featureFieldIds: string[] }[],
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
*/
@@ -117,14 +126,18 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
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')
const allFeatures = await list(FEATURES(), [Query.equal('customerId', customerId)])
const features = allFeatures.filter((f) => f.state !== 'disabled')
// Bestellangaben (IP, Browser, Text, Zeit) leeren wir an jedem Dokument des Kunden, auch am schon abgeschalteten
const withFields = allFeatures.filter(hasConsentFields)
const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)])
if (!posts.length && !features.length && !consents.length) continue
if (!posts.length && !features.length && !consents.length && !withFields.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),
consentIds: consents.map((c) => c.$id),
featureFieldIds: withFields.map((f) => f.$id),
})
}
@@ -177,12 +190,12 @@ async function deleteQuietly(collection, id) {
* Daten fuer immer liegen. Lehnt Stripe die Aenderung 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, featureConsents: number },
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: 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 }
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0 }
const manual = []
const failed = []
const held = []
@@ -218,6 +231,19 @@ export async function applyDeletions(plan) {
})
if (off) deleted.features += 1
}
// IP, Browser, Text und Zeit der Bestellung am Funktionsdokument (auch am abgeschalteten): das Dokument bleibt, die Angaben gehen
for (const id of entry.featureFieldIds || []) {
const blanked = await attempt('portalFeatures', id, async () => {
try {
await updateDocument(FEATURES(), id, BLANK_FEATURE_FIELDS)
return true
} catch (err) {
if (isGone(err)) return false
throw err
}
})
if (blanked) deleted.featureFields += 1
}
// Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht
for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1
@@ -288,8 +314,9 @@ export function formatReport(plan, result = null) {
for (const n of plan.news || []) {
const images = n.posts.filter((p) => p.imageId).length
const consents = n.consentIds?.length || 0
const fields = n.featureFieldIds?.length || 0
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`
`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`
)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen')
@@ -308,7 +335,8 @@ export function formatReport(plan, result = null) {
'',
`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.`,
`Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`
`Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`,
`Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`
)
if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))