Shop J3: Tagesjob und Stundenjob, Löschfristen, Kundenlöschung

- shop/jobs.js: taeglicherJob (Käuferdaten bezahlter Bestellungen und Widerrufe 3 Jahre nach Ende des Bestelljahres anonymisieren, Grenze 1. Januar Berlin; unbezahlte/stornierte Bestellungen nach 30 Tagen löschen; Shops seit 90 Tagen disabled samt Artikeln, Bestellungen, Widerrufen, Bildern und shopConfig löschen, Funktionsdokument bleibt disabled mit Vermerk), seitenweise mit Deckel je Lauf, Schritte und Dokumente einzeln abgesichert; stuendlicherJob räumt nie beanspruchte Bilder auf
- shop/loeschen.js: gemeinsamer Baustein zum Löschen aller Daten eines Shops (Tagesjob und Löschfristen)
- shop/kundenLoeschung.js + customerAdmin.js: Kundenlöschung schaltet den Webshop ab (kunde_geloescht) und schließt die Shops
- retention.js: fällige Kunden verlieren auch ihre Shopdaten (vor den Abo-Dokumenten, bei Fehler held)
- server/index.js: Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) registriert
- Tests: shopJobs.test.js, retention.test.js erweitert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:15:20 +00:00
1 parent 73820fb1e6
commit b8055b336c
8 files changed
+941 -8

No files matched your search

+91
View File
@@ -18,12 +18,17 @@ const aw = await startAppwriteMock({
newsPosts: schemaKeys('newsPosts'),
portalFeatures: schemaKeys('portalFeatures'),
featureConsents: schemaKeys('featureConsents'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
},
})
const st = await startFakeStripe()
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news')
process.env.SHOP_UPLOAD_DIR = path.join(uploadDir, 'shop')
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
const { run } = await import('../scripts/loeschfristen.mjs')
@@ -580,3 +585,89 @@ test('Nachweis-Liste an der Grenze: findDueDeletions bricht ab, bevor etwas gepl
await assert.rejects(findDueDeletions(JAN_2026, { limit: grenze }), /Liste abgeschnitten, Paginierung nötig \(featureConsents/)
assert.equal([...aw.docs('featureConsents').values()].filter((c) => c.customerId === 'rs').length, grenze, 'nichts gelöscht')
})
// ------------------------------------------------------------------------------------------------ Webshop (J3)
const SHOP_KEY = 'abcdefghijklmnop'
function seedFaelligMitShop(kunde, siteKey = SHOP_KEY) {
sub(`${kunde}_sub`, kunde, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event(`${kunde}_ev`, kunde, `${kunde}_sub`)
stripeSub(`${kunde}_sub`, kunde)
const shopId = `f_${kunde}_shop`
aw.seed('shopConfig', { $id: shopId, customerId: kunde, projectId: `${kunde}p`, siteKey, geoeffnet: false })
aw.seed('shopProducts', { $id: `${kunde}_prod`, shopId, customerId: kunde, aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' })
aw.seed('shopOrders', { $id: `${kunde}_ord1`, shopId, customerId: kunde, bestellnummer: '26-A', status: 'versendet', kaeuferEmail: 'k@example.com', bestellung: '{}' })
aw.seed('shopOrders', { $id: `${kunde}_ord2`, shopId, customerId: kunde, bestellnummer: '26-B', status: 'storniert', kaeuferEmail: 'k@example.com', bestellung: '{}' })
aw.seed('shopWiderrufe', { $id: `${kunde}_wid`, shopId, customerId: kunde, bestellnummer: '26-A', name: 'K', email: 'k@example.com', nachricht: 'x' })
const ordner = path.join(uploadDir, 'shop', siteKey)
mkdirSync(ordner, { recursive: true })
writeFileSync(path.join(ordner, 'bild-l.webp'), 'x')
return shopId
}
test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe, Bilder und shopConfig; andere Kunden bleiben; Bericht nennt es', async () => {
seedFaelligMitShop('sh1')
seedFaelligMitShop('sh2', 'bcdefghijklmnopq')
// nicht fälliger Kunde (laufendes Abo) mit Shop
sub('sh3_sub', 'sh3', { status: 'active' })
aw.seed('shopConfig', { $id: 'f_sh3_shop', customerId: 'sh3', projectId: 'sh3p', siteKey: 'cdefghijklmnopqr' })
aw.seed('shopOrders', { $id: 'sh3_ord', shopId: 'f_sh3_shop', customerId: 'sh3', bestellnummer: '26-C', status: 'versendet', bestellung: '{}' })
const plan = await findDueDeletions(JAN_2026)
const eintrag = plan.news.find((n) => n.customerId === 'sh1')
assert.deepEqual(eintrag.shops, [{ id: 'f_sh1_shop', siteKey: SHOP_KEY }])
assert.ok(!plan.news.some((n) => n.customerId === 'sh3'), 'nicht fälliger Kunde bleibt außerhalb')
assert.match(formatReport(plan), /Kunde sh1: .*1 Webshop\(s\) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen/)
// ältere Einträge ohne Shop tragen den Schlüssel nicht (Plan bleibt für andere Daten gleich)
assert.ok(plan.news.filter((n) => !n.shops).every((n) => !('shops' in n)))
const probe = await run({ mode: '', now: JAN_2026, log: () => {} })
assert.equal(probe.applied, false)
assert.ok(aw.docs('shopOrders').has('sh1_ord1'))
const reihenfolge = []
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1])
return echt(url, optionen)
}
let result
try {
result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'sh1'), news: plan.news.filter((n) => n.customerId === 'sh1'), takeover: [] })
} finally {
globalThis.fetch = echt
}
assert.deepEqual(result.failed, [])
assert.deepEqual(result.held, [])
assert.equal(result.deleted.shops, 1)
assert.equal(result.deleted.shopDocs, 4)
for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopConfig', ['f_sh1_shop']]]) {
for (const docId of ids) assert.ok(!aw.docs(col).has(docId), `${col} ${docId} weg`)
}
assert.ok(!existsSync(path.join(uploadDir, 'shop', SHOP_KEY)), 'Bilder weg')
assert.ok(aw.docs('shopOrders').has('sh2_ord1') && aw.docs('shopConfig').has('f_sh2_shop'), 'anderer fälliger Kunde (nicht im Lauf) bleibt')
assert.ok(existsSync(path.join(uploadDir, 'shop', 'bcdefghijklmnopq')))
assert.ok(aw.docs('shopOrders').has('sh3_ord') && aw.docs('shopConfig').has('f_sh3_shop'), 'laufender Kunde bleibt')
// Reihenfolge: Shopdaten vor den Vertragsereignissen und Abo-Dokumenten
assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents'))
assert.ok(reihenfolge.indexOf('contractEvents') < reihenfolge.indexOf('subscriptions'))
assert.match(formatReport(plan, result), /Webshop: 1 Shop\(s\) gelöscht \(4 Dokument\(e\)/)
})
test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente stehen (held) und der nächste Lauf setzt fort', async () => {
seedFaelligMitShop('sh4', 'defghijklmnopqrs')
const plan = await findDueDeletions(JAN_2026)
aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' })
const result = await applyDeletions(planVon(plan, 'sh4'))
assert.deepEqual(result.held, ['sh4'])
assert.equal(result.failed[0].what, 'shopConfig')
assert.ok(aw.docs('subscriptions').has('sh4_sub') && aw.docs('contractEvents').has('sh4_ev'), 'Abo-Daten bleiben')
assert.ok(aw.docs('shopConfig').has('f_sh4_shop'), 'shopConfig bleibt, bis alles weg ist')
const zweiter = await findDueDeletions(JAN_2026)
assert.ok(zweiter.customers.some((c) => c.customerId === 'sh4'), 'der Kunde ist weiter fällig')
const nochmal = await applyDeletions(planVon(zweiter, 'sh4'))
assert.deepEqual(nochmal.failed, [])
assert.deepEqual(nochmal.held, [])
assert.ok(!aw.docs('shopConfig').has('f_sh4_shop') && !aw.docs('subscriptions').has('sh4_sub'))
})