Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a629b2519b
commit
5b5b4b90eb
4 files changed
+106
-15
No files matched your search
@@ -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) */
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
+12
-10
@@ -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)
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
Reference in new issue
Block a user