From 5b5b4b90eb1dd0bf98e1512cd776edfe950578a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:38:00 +0000 Subject: [PATCH] Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen Co-Authored-By: Claude Sonnet 5.5 --- scripts/feature-setup.mjs | 2 + server/services/retention.js | 26 ++++++++++--- test/featureSetup.test.js | 22 ++++++----- test/retention.test.js | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 15 deletions(-) diff --git a/scripts/feature-setup.mjs b/scripts/feature-setup.mjs index 580e887..b0c8215 100644 --- a/scripts/feature-setup.mjs +++ b/scripts/feature-setup.mjs @@ -53,6 +53,8 @@ export const FEATURE_CONSENT_ATTRIBUTES = [ export const FEATURE_CONSENT_INDEXES = [ { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, { key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] }, + // Loeschfristen (services/retention.js): alle Nachweise eines Kunden, drei Jahre nach Ablauf des Jahres, in dem sein letztes Abo endet + { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, ] /** true, wenn das Ergebnis von main() Luecken meldet (Exit-Code 1 im Pruefmodus) */ diff --git a/server/services/retention.js b/server/services/retention.js index 30dcec4..bb968b6 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -21,6 +21,10 @@ import { filesOf, STATUS } from './takeoverCredits.js' * - 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. + * - Funktionsbestellungen: Die Zustimmungsnachweise zu bestellten Funktionen (featureConsents, Datenschutzerklaerung Abschnitt 7: + * "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. * 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 @@ -32,6 +36,7 @@ const EVENTS = () => config.collections.contractEvents const TAKEOVER = () => config.collections.takeoverCredits const NEWS_POSTS = () => config.collections.newsPosts const FEATURES = () => config.collections.portalFeatures +const CONSENTS = () => config.collections.featureConsents export const RETENTION_YEARS = 3 export const RECEIPT_YEARS = 8 @@ -68,6 +73,7 @@ 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[] }[], * takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[], * pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>} */ @@ -112,11 +118,13 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } 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 + const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)]) + if (!posts.length && !features.length && !consents.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), }) } @@ -165,12 +173,12 @@ async function deleteQuietly(collection, id) { * 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 }, + * newsPosts: number, newsImages: number, features: number, featureConsents: 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 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0 } const manual = [] const failed = [] const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) @@ -225,6 +233,10 @@ export async function applyDeletions(plan) { }) if (off) deleted.features += 1 } + // Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht, der naechste Lauf findet den Eintrag erneut + for (const id of entry.consentIds || []) { + if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 + } } for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) { @@ -251,7 +263,10 @@ export function formatReport(plan, result = null) { } for (const n of plan.news || []) { const images = n.posts.filter((p) => p.imageId).length - 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`) + const consents = n.consentIds?.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` + ) } const del = plan.takeover.filter((t) => t.action === 'loeschen') const report = plan.takeover.filter((t) => t.action === 'melden') @@ -268,7 +283,8 @@ export function formatReport(plan, result = null) { 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.` + `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.` ) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) diff --git a/test/featureSetup.test.js b/test/featureSetup.test.js index 3de0fef..cf3f931 100644 --- a/test/featureSetup.test.js +++ b/test/featureSetup.test.js @@ -25,7 +25,7 @@ const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs') const CONSENT_KEYS = [ 'customerId', 'projectId', 'featureKey', 'stripeSessionId', 'acceptedAt', 'fassungen', 'texte', 'textHash', 'ip', 'userAgent', 'konto', ] -const CONSENT_INDEX_KEYS = ['idx_session', 'idx_project_feature'] +const CONSENT_INDEX_KEYS = ['idx_session', 'idx_project_feature', 'idx_customer'] // Ohne Angaben ist featureConsents komplett vorhanden (Attribute und Indizes): die Faelle fuer portalFeatures bleiben so // unberuehrt; die Faelle fuer featureConsents setzen consents/consentAttrs/indexes selbst. @@ -177,10 +177,11 @@ test('Schema featureConsents: 11 Schluessel mit Typ und Groesse, alle optional', assert.ok(nach.textHash.size >= 64 && nach.customerId.size >= 36 && nach.projectId.size >= 36) }) -test('Schema featureConsents: beide Indizes mit den Attributen aus der Vorgabe', () => { +test('Schema featureConsents: drei Indizes mit den Attributen aus der Vorgabe (idx_customer für die Löschfristen)', () => { assert.deepEqual(FEATURE_CONSENT_INDEXES, [ { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, { key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] }, + { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, ]) const keys = new Set(CONSENT_KEYS) for (const index of FEATURE_CONSENT_INDEXES) for (const attr of index.attributes) assert.ok(keys.has(attr), `${index.key}: ${attr}`) @@ -199,7 +200,7 @@ test('schemaKeys(featureConsents) liefert dieselben Schluessel, der Nachbau nimm await assert.rejects(() => createDocument(config.collections.featureConsents, { gibtEsNicht: 1 }, 'fc_schema2'), /Unknown attribute/) }) -test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen und 2 Indizes an', async () => { +test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen und 3 Indizes an', async () => { const client = mockClient({ attrs: KEYS, consents: false, consentAttrs: [], indexes: [] }) const log = [] const result = await main({ client, log: (zeile) => log.push(zeile) }) @@ -210,17 +211,17 @@ test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen assert.equal(client.calls.attrs.find((a) => a.key === 'texte').size, 20000) assert.ok(client.calls.attrs.every((a) => a.required === false)) assert.deepEqual(client.calls.waits, CONSENT_KEYS.map((key) => `featureConsents.${key}`)) - assert.deepEqual(client.calls.indexes, ['featureConsents.idx_session', 'featureConsents.idx_project_feature']) + assert.deepEqual(client.calls.indexes, ['featureConsents.idx_session', 'featureConsents.idx_project_feature', 'featureConsents.idx_customer']) // Reihenfolge: Collection, alle Attribute, Warten auf alle Attribute, erst dann die Indizes const order = client.calls.order assert.equal(order[0], 'collection featureConsents') - assert.equal(order.findIndex((e) => e.startsWith('index ')), order.length - 2, 'die Indizes kommen zuletzt') + assert.equal(order.findIndex((e) => e.startsWith('index ')), order.length - 3, 'die Indizes kommen zuletzt') assert.ok(order.lastIndexOf('wait featureConsents.konto') < order.indexOf('index featureConsents.idx_session')) assert.ok(order.indexOf('attribute featureConsents.konto') < order.indexOf('wait featureConsents.customerId')) assert.equal(result.missingCollection, true) assert.equal(result.missing.length, 11) assert.ok(result.missing.every((a) => a.collection === 'featureConsents')) - assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_session', 'idx_project_feature']) + assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_session', 'idx_project_feature', 'idx_customer']) assert.ok(log.includes('+ Collection featureConsents')) assert.ok(log.includes('+ Index featureConsents.idx_project_feature')) }) @@ -238,7 +239,7 @@ test('main ergaenzt an vorhandener Collection nur fehlende Attribute und Indizes const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'konto' && key !== 'textHash'), - indexes: ['idx_session'], + indexes: ['idx_session', 'idx_customer'], }) const result = await main({ client, log: () => {} }) assert.deepEqual(client.calls.collections, [], 'die Collection wird nicht noch einmal angelegt') @@ -272,17 +273,18 @@ test('main --check schreibt nie, meldet featureConsents FEHLT und liefert die Lu for (const key of CONSENT_KEYS) assert.ok(log.includes(`- featureConsents.${key} FEHLT`), key) assert.ok(log.includes('- featureConsents.idx_session FEHLT')) assert.ok(log.includes('- featureConsents.idx_project_feature FEHLT')) + assert.ok(log.includes('- featureConsents.idx_customer FEHLT')) assert.ok(!log.some((zeile) => zeile.includes('portalFeatures') && zeile.includes('FEHLT'))) assert.ok(!log.some((zeile) => zeile.startsWith('+'))) assert.equal(result.missingCollection, true) assert.deepEqual(result.missing.map((a) => a.key), CONSENT_KEYS) - assert.equal(result.missingIndexes.length, 2) + assert.equal(result.missingIndexes.length, 3) assert.equal(hatLuecken(result), true) - assert.equal(log.at(-1), '14 Eintrag/Eintraege fehlen.') + assert.equal(log.at(-1), '15 Eintrag/Eintraege fehlen.') }) test('main --check meldet je fehlendes Attribut und je fehlenden Index einer vorhandenen Collection', async () => { - const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'texte'), indexes: ['idx_project_feature'] }) + const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'texte'), indexes: ['idx_project_feature', 'idx_customer'] }) const log = [] const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE) diff --git a/test/retention.test.js b/test/retention.test.js index 92deac0..d042e90 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -17,6 +17,7 @@ const aw = await startAppwriteMock({ takeoverCredits: schemaKeys('takeoverCredits'), newsPosts: schemaKeys('newsPosts'), portalFeatures: schemaKeys('portalFeatures'), + featureConsents: schemaKeys('featureConsents'), }, }) const st = await startFakeStripe() @@ -327,3 +328,73 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () = analyseDeps.client = ok } }) + +test('retention_loescht_featureConsents: Zustimmungsnachweise fälliger Kunden werden geplant, gelöscht und berichtet; andere bleiben', async () => { + const consent = (id, customerId, projectId, featureKey) => aw.seed('featureConsents', { + $id: id, customerId, projectId, featureKey, stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00', + fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com', + }) + // fälliger Kunde: Abo endete 2022, zwei Funktionsbestellungen (Terminplaner und News) in zwei Projekten, sonst nichts + sub('fc_sub', 'fc', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + stripeSub('fc_sub', 'fc') + consent('fc_c1', 'fc', 'fcp1', 'termine') + consent('fc_c2', 'fc', 'fcp2', 'news') + // nicht fälliger Kunde (laufendes Abo) mit Nachweis, und ein Kunde ohne Abo (nur News) mit Nachweis: beide bleiben wie bisher unberührt + sub('fc2_sub', 'fc2', { status: 'active' }) + consent('fc2_c1', 'fc2', 'fc2p1', 'termine') + consent('fc3_c1', 'fc3', 'fc3p1', 'news') + aw.seed('newsPosts', { $id: 'fc3_post', customerId: 'fc3', projectId: 'fc3p1', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' }) + + // die Abfrage nach customerId hat im Schema (scripts/feature-setup.mjs) einen Index + const { FEATURE_CONSENT_INDEXES } = await import('../scripts/feature-setup.mjs') + assert.ok(FEATURE_CONSENT_INDEXES.some((i) => i.attributes.length === 1 && i.attributes[0] === 'customerId'), 'Index auf featureConsents.customerId fehlt') + + const plan = await findDueDeletions(JAN_2026) + const eintrag = plan.news.find((n) => n.customerId === 'fc') + assert.ok(eintrag, 'ein fälliger Kunde nur mit Nachweisen (ohne Beiträge und Funktionen) steht im Plan') + assert.deepEqual(eintrag.consentIds.sort(), ['fc_c1', 'fc_c2']) + assert.deepEqual(eintrag.posts, []) + assert.deepEqual(eintrag.featureIds, []) + assert.ok(!plan.news.some((n) => n.customerId === 'fc2' || n.customerId === 'fc3'), 'nicht fälliger Kunde und Kunde ohne Abo bleiben außerhalb') + assert.match(formatReport(plan), /Kunde fc: 0 News-Beitrag\/-Beiträge mit 0 Foto\(s\) zu löschen, 0 Funktion\(en\) abzuschalten, 2 Zustimmungsnachweis\(e\) zu löschen/) + + // Probelauf löscht nichts + const probe = await run({ mode: '', now: JAN_2026, log: () => {} }) + assert.equal(probe.applied, false) + assert.ok(aw.docs('featureConsents').has('fc_c1') && aw.docs('featureConsents').has('fc_c2')) + + const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'fc'), news: plan.news.filter((n) => n.customerId === 'fc') }) + assert.deepEqual(result.failed, []) + assert.equal(result.deleted.featureConsents, 2) + assert.ok(!aw.docs('featureConsents').has('fc_c1') && !aw.docs('featureConsents').has('fc_c2')) + assert.ok(aw.docs('featureConsents').has('fc2_c1') && aw.docs('featureConsents').has('fc3_c1'), 'Nachweise anderer Kunden bleiben') + assert.match(formatReport(plan, result), /Zustimmungsnachweise zu Funktionsbestellungen: 2 gelöscht\./) + // ein zweiter Lauf findet nichts mehr für diesen Kunden + const danach = await findDueDeletions(JAN_2026) + assert.ok(!danach.news.some((n) => n.customerId === 'fc')) +}) + +test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweises stoppt den Rest nicht und landet in failed; ältere Pläne ohne consentIds laufen weiter', async () => { + const consent = (id, customerId) => aw.seed('featureConsents', { + $id: id, customerId, projectId: 'fcxp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2021-02-01T10:00:00.000+00:00', + fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'Mitarbeiter: Test', + }) + consent('fcx_c1', 'fcx') + consent('fcx_c2', 'fcx') + aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' }) + const result = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] }) + assert.equal(result.failed.length, 1) + assert.equal(result.failed[0].what, 'featureConsents') + assert.equal(result.failed[0].id, 'fcx_c1') + assert.match(result.failed[0].reason, /kaputt/) + assert.equal(result.deleted.featureConsents, 1) + assert.ok(aw.docs('featureConsents').has('fcx_c1') && !aw.docs('featureConsents').has('fcx_c2')) + // der nächste Lauf holt den Rest nach; ein Eintrag, der inzwischen weg ist (404), zählt nicht + const nochmal = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] }) + assert.deepEqual(nochmal.failed, []) + assert.equal(nochmal.deleted.featureConsents, 1) + // Pläne ohne consentIds (ältere Form) bleiben gültig + const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [] }] }) + assert.equal(alt.deleted.featureConsents, 0) + assert.deepEqual(alt.failed, []) +})