diff --git a/server/index.js b/server/index.js index 3b82457..6911683 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' +import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -231,6 +232,29 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(termineTagesJob, 180 * 1000).unref() setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref() + // Webshop: jede Minute offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (75 s nach dem Start beginnend), stuendlich nie + // beanspruchte Artikelbilder aufraeumen (130 s), taeglich Kaeuferdaten anonymisieren, unbezahlte Bestellungen und lange abgeschaltete + // Shops loeschen (200 s). Bei SHOP_ENABLED=off kehren Minuten- und Stundenjob sofort zurueck; der Tagesjob laeuft dann nur, wenn es + // noch Shop-Dokumente gibt. Die Jobs melden Einzelfehler selbst (ohne Kaeuferdaten); hier faengt der Rahmen alles Unerwartete ab. + const shopMinuten = () => shopMinutenJob(new Date()).catch((err) => console.warn('[shop] Minutenjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopMinuten() + setInterval(shopMinuten, 60 * 1000).unref() + }, 75 * 1000).unref() + const shopStunden = () => shopStuendlicherJob(new Date()).catch((err) => console.warn('[shop] Stundenjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopStunden() + setInterval(shopStunden, 60 * 60 * 1000).unref() + }, 130 * 1000).unref() + const shopTages = () => + shopTaeglicherJob(new Date()) + .then((r) => r && !r.aus && !r.uebersprungen && console.log(`[shop] Tagesjob: ${JSON.stringify(r)}`)) + .catch((err) => console.warn('[shop] Tagesjob fehlgeschlagen:', err.message)) + setTimeout(() => { + shopTages() + setInterval(shopTages, 24 * 60 * 60 * 1000).unref() + }, 200 * 1000).unref() + // Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten) const repoAssign = () => runRepoAssignJob({ now: new Date() }).catch((err) => diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 5a2aae7..1396589 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -291,6 +291,22 @@ export async function deleteCustomerWithPortalAccess(customerId) { console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message) } + // Webshop abschalten: Kaeufer koennen Bestellungen nicht mehr aufgeben, den Widerruf aber weiter erklaeren; die Shopdaten loescht der + // Tagesjob 90 Tage nach disabledAt. Den Shop selbst schliessen wir sofort (geoeffnet=false) und leeren den Speicher der oeffentlichen + // Schnittstelle. Jeder Teilschritt ist abgesichert: Fehler halten das Loeschen nicht auf (wie bei Analyse und Terminplaner). + try { + const { disableCustomerFeatures } = await import('./siteFeatures.js') + await disableCustomerFeatures(customerId, 'shop', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) + } catch (err) { + console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht abgeschaltet:`, err.message) + } + try { + const { schliesseShopsVonKunde } = await import('./shop/kundenLoeschung.js') + await schliesseShopsVonKunde(customerId) + } catch (err) { + console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message) + } + // Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument // und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf. try { diff --git a/server/services/retention.js b/server/services/retention.js index 303c943..3206608 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -3,6 +3,7 @@ import path from 'node:path' import { config } from '../config.js' import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' +import { loescheShopDaten } from './shop/loeschen.js' import { disable } from './siteFeatures.js' import { getStripe } from './stripeBilling.js' import { filesOf, STATUS } from './takeoverCredits.js' @@ -28,6 +29,10 @@ import { filesOf, STATUS } from './takeoverCredits.js' * - 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). + * - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Artikelbilder, shopConfig; + * plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der + * Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem + * Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js). * 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 @@ -40,6 +45,8 @@ const TAKEOVER = () => config.collections.takeoverCredits const NEWS_POSTS = () => config.collections.newsPosts const FEATURES = () => config.collections.portalFeatures const CONSENTS = () => config.collections.featureConsents +const SHOP_CONFIG = () => config.collections.shopConfig +const SHOP_LOESCH_DECKEL = 2000 /** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */ const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt'] @@ -78,6 +85,17 @@ function endOf(doc) { return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd) } +/** Shops (shopConfig) eines Kunden als { id, siteKey }. Gibt es die Collection noch nicht (Setup-Skript nicht gelaufen), gibt es keine Shops. */ +async function shopsOf(customerId, list) { + try { + const docs = await list(SHOP_CONFIG(), [Query.equal('customerId', customerId)]) + return docs.map((d) => ({ id: d.$id, siteKey: d.siteKey || '' })) + } catch (err) { + if (err?.type === 'collection_not_found' || err?.code === 'collection_not_found') return [] + throw err + } +} + /** * @param {Date} [now] * @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[], @@ -131,13 +149,15 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } // 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 && !withFields.length) continue + const shops = await shopsOf(customerId, list) + if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.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), + ...(shops.length ? { shops } : {}), }) } @@ -190,12 +210,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, featureFields: number }, + * newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: 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, featureFields: 0 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0 } const manual = [] const failed = [] const held = [] @@ -244,10 +264,22 @@ export async function applyDeletions(plan) { }) if (blanked) deleted.featureFields += 1 } - // Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht + // Zustimmungsnachweise der Funktionsbestellungen: ein Fehler stoppt den Rest nicht for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } + // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) + let shopRest = SHOP_LOESCH_DECKEL + for (const shop of entry.shops || []) { + await attempt('shopConfig', shop.id, async () => { + const weg = await loescheShopDaten({ shopId: shop.id, siteKey: shop.siteKey, deckel: shopRest }) + shopRest -= weg.geloescht + deleted.shopDocs += weg.geloescht + if (!weg.vollstaendig) throw new Error('Shopdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter') + deleted.shops += 1 + return true + }) + } } const newsOf = new Map((plan.news || []).map((entry) => [entry.customerId, entry])) @@ -315,8 +347,9 @@ export function formatReport(plan, result = null) { const images = n.posts.filter((p) => p.imageId).length const consents = n.consentIds?.length || 0 const fields = n.featureFieldIds?.length || 0 + const shops = n.shops?.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, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren` + `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${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}` ) } const del = plan.takeover.filter((t) => t.action === 'loeschen') @@ -336,7 +369,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.`, - `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.` + `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`, + ...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : []) ) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) diff --git a/server/services/shop/jobs.js b/server/services/shop/jobs.js index d5c5025..d9a4c50 100644 --- a/server/services/shop/jobs.js +++ b/server/services/shop/jobs.js @@ -1,8 +1,15 @@ -import { pruefeOffene } from './bestellungen.js' +import { config } from '../../config.js' +import { deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { heute, zuUtc } from '../termine/zeit.js' +import { zeitMs } from '../termine/util.js' +import { raeumeUnbeanspruchteAuf } from './bilder.js' +import { bestellDaten, pruefeOffene } from './bestellungen.js' +import { leereCache } from './kontext.js' +import { loescheShopDaten } from './loeschen.js' import { shopModus } from './schalter.js' /** - * Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3 ergaenzt den Tagesjob und die Registrierung in index.js). + * Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3: Stundenjob, Tagesjob und die Registrierung in index.js). * Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten Takt) und wirft nie. */ @@ -29,3 +36,226 @@ export async function minutenJob(now = new Date()) { if (shopModus() === 'off') return { aus: true } return einzeln('minutenJob', () => pruefeOffene(now)) } + +/** Stundenjob: nie an einen Artikel gebundene, hochgeladene Bilder nach 24 h loeschen. Bei SHOP_ENABLED=off aus. */ +export async function stuendlicherJob(now = new Date()) { + if (shopModus() === 'off') return { aus: true } + return einzeln('stuendlicherJob', async () => ({ bilderEntfernt: await raeumeUnbeanspruchteAuf(now) })) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Tagesjob (Spezifikation 9 / W11) + +const TAG_MS = 24 * 60 * 60 * 1000 +const STANDARD_DECKEL = 2000 +const SEITE = 100 +/** Marker in portalFeatures.adminNote nach der Loeschung der Shopdaten (hoechstens 300 Zeichen im Feld). */ +const MARKER = 'Shopdaten gelöscht am ' +const MARKER_RE = /^Shopdaten gelöscht am (\d{4}-\d{2}-\d{2})/ + +const deckel = () => (Number.isInteger(config.shop.loeschDeckel) && config.shop.loeschDeckel > 0 ? config.shop.loeschDeckel : STANDARD_DECKEL) + +/** Fehlertext fuer das Log: nur Name, Status und gekuerzte Meldung (Appwrite-Meldungen nennen hoechstens Dokument-IDs, nie Kaeuferdaten). */ +const fehlerText = (err) => `${err?.name}${err?.status ? ` ${err.status}` : ''}: ${String(err?.message ?? '').slice(0, 160)}` + +/** + * Anfang des ersten Jahres, dessen Bestellungen noch NICHT anonymisiert werden: 1. Januar (Berlin, 00:00) von (aktuelles Berliner + * Jahr - anonymisierenJahre). Eine Bestellung aus dem Jahr Y wird anonymisiert, sobald now >= 1. Januar von Y + anonymisierenJahre + 1 + * (3 Jahre nach dem Ende des Bestelljahres), also genau dann, wenn erstelltAt vor dieser Grenze liegt. + */ +export function anonymisierGrenze(now, jahre = config.shop.anonymisierenJahre) { + const jahr = Number(heute(now).slice(0, 4)) - jahre + return zuUtc(`${String(jahr).padStart(4, '0')}-01-01`, '00:00') +} + +/** Gibt es Shop-Dokumente? Eine Abfrage mit limit 1, ohne Kunden- oder Kaeuferdaten. */ +async function gibtShops() { + return (await listDocuments(config.collections.shopConfig, [Query.limit(1)])).length > 0 +} + +/** Ein Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht, wird gezaehlt und ohne Personendaten geloggt. */ +async function schritt(lauf, name, arbeit) { + try { + await arbeit() + } catch (err) { + lauf.fehler += 1 + console.warn(`[shop-jobs] taeglicherJob: ${name} fehlgeschlagen: ${fehlerText(err)}`) + } +} + +/** + * Liest eine Abfrage seitenweise (nach $id, 100 je Seite, nur die genannten Felder; nur lesen, damit der Cursor gueltig bleibt) und + * sammelt die Treffer von `passt`, hoechstens max. => { treffer, gedeckelt } (gedeckelt: der Deckel wurde erreicht, es kann mehr geben) + */ +async function sammle(sammlung, filter, felder, passt, max) { + const treffer = [] + let cursor = null + for (;;) { + const seite = await listDocuments(sammlung, [ + ...filter, + Query.select(['$id', ...felder]), + Query.orderAsc('$id'), + Query.limit(SEITE), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + ]) + for (const doc of seite) { + if (!passt(doc)) continue + if (treffer.length >= max) return { treffer, gedeckelt: true } + treffer.push(doc) + } + if (seite.length < SEITE) return { treffer, gedeckelt: false } + cursor = seite[seite.length - 1].$id + } +} + +/** Wendet arbeit auf die gesammelten Treffer an; ein Fehler an einem Dokument wird gezaehlt und geloggt, die uebrigen kommen trotzdem dran. */ +async function bearbeite(lauf, sammlung, { treffer, gedeckelt }, art, arbeit) { + if (gedeckelt) lauf.gedeckelt = true + for (const doc of treffer) { + try { + await arbeit(doc) + lauf[art] -= 1 + } catch (err) { + lauf.fehler += 1 + console.warn(`[shop-jobs] taeglicherJob: ${sammlung} ${doc.$id} fehlgeschlagen: ${fehlerText(err)}`) + } + } +} + +/** 2. Unbezahlte Bestellungen (storniert, oder offen und laengst abgelaufen) nach unbezahltLoeschTage Tagen loeschen. */ +async function loescheUnbezahlte(lauf, shopId, now) { + const sammlung = config.collections.shopOrders + const grenze = now.getTime() - config.shop.unbezahltLoeschTage * TAG_MS + const gefunden = await sammle( + sammlung, + [Query.equal('shopId', shopId), Query.equal('status', ['storniert', 'offen']), Query.lessThan('erstelltAt', new Date(grenze).toISOString())], + ['bezahltAt', 'erstelltAt'], + // eine bezahlte Bestellung wird hier nie geloescht, auch wenn ihr Status nicht stimmt + (doc) => !doc.bezahltAt && zeitMs(doc.erstelltAt) < grenze, + lauf.loeschRest, + ) + await bearbeite(lauf, sammlung, gefunden, 'loeschRest', async (doc) => { + try { + await deleteDocument(sammlung, doc.$id) + } catch (err) { + if (err?.status !== 404) throw err + } + lauf.zaehler.unbezahltGeloescht += 1 + }) +} + +/** 1. Kaeuferdaten bezahlter Bestellungen 3 Jahre nach Ende des Bestelljahres entfernen; Positionen, Betraege, Nummer, Status bleiben. */ +async function anonymisiereBestellungen(lauf, shopId, grenze) { + const sammlung = config.collections.shopOrders + const gefunden = await sammle( + sammlung, + [Query.equal('shopId', shopId), Query.lessThan('erstelltAt', grenze.toISOString())], + ['anonymisiertAt', 'erstelltAt'], + (doc) => !doc.anonymisiertAt && zeitMs(doc.erstelltAt) < grenze.getTime(), + lauf.anonymRest, + ) + await bearbeite(lauf, sammlung, gefunden, 'anonymRest', async (kopf) => { + const doc = await getDocument(sammlung, kopf.$id) + if (doc.anonymisiertAt) return + const daten = bestellDaten(doc) + delete daten.lieferadresse + delete daten.rechnungsadresse + daten.kaeufer = { name: '', email: '', telefon: '' } + await updateDocument(sammlung, doc.$id, { + kaeuferEmail: '', + bestellung: JSON.stringify(daten), + anonymisiertAt: lauf.jetzt.toISOString(), + }) + lauf.zaehler.bestellungenAnonymisiert += 1 + }) +} + +/** 3. Widerrufe: Name, E-Mail, Nachricht nach derselben Frist (eingangAt) leeren. Die Bestellnummer bleibt (keine Personenangabe). */ +async function anonymisiereWiderrufe(lauf, shopId, grenze) { + const sammlung = config.collections.shopWiderrufe + const gefunden = await sammle( + sammlung, + [Query.equal('shopId', shopId), Query.lessThan('eingangAt', grenze.toISOString())], + ['name', 'email', 'nachricht', 'eingangAt'], + (doc) => (doc.name || doc.email || doc.nachricht) && zeitMs(doc.eingangAt) < grenze.getTime(), + lauf.anonymRest, + ) + await bearbeite(lauf, sammlung, gefunden, 'anonymRest', async (doc) => { + await updateDocument(sammlung, doc.$id, { name: '', email: '', nachricht: '' }) + lauf.zaehler.widerrufeAnonymisiert += 1 + }) +} + +/** 4. Shops seit mehr als loeschTageNachAbschalten Tagen `disabled`: alle Shopdaten samt Bildern und shopConfig loeschen. */ +async function loescheAbgeschaltete(lauf, now) { + const grenze = now.getTime() - config.shop.loeschTageNachAbschalten * TAG_MS + const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'shop'), Query.equal('state', 'disabled')]) + for (const f of funktionen) { + if (!(zeitMs(f.disabledAt) < grenze)) continue + const marker = MARKER_RE.exec(String(f.adminNote ?? '')) + if (marker && marker[1] >= String(f.disabledAt).slice(0, 10)) continue // schon geloescht (ein spaeteres Abschalten setzt disabledAt neu) + if (lauf.loeschRest <= 0) { + lauf.gedeckelt = true + return + } + try { + // vor jeder Seite frisch lesen: das Team kann die Funktion zwischenzeitlich wieder einschalten + const nochAus = async () => { + const frisch = await getDocument(config.collections.portalFeatures, f.$id) + return frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze + } + const erg = await loescheShopDaten({ shopId: f.$id, siteKey: f.siteKey, deckel: lauf.loeschRest, nochGueltig: nochAus }) + lauf.loeschRest -= erg.geloescht + lauf.zaehler.shopDokumenteGeloescht += erg.geloescht + if (f.siteKey) leereCache(f.siteKey) + if (erg.vollstaendig) { + lauf.zaehler.shopsGeloescht += 1 + if (await nochAus()) { + await updateDocument(config.collections.portalFeatures, f.$id, { adminNote: `${MARKER}${heute(now)}`.slice(0, 300) }) + } + } else if (!erg.abgebrochen) { + lauf.gedeckelt = true + } + } catch (err) { + lauf.fehler += 1 + console.warn(`[shop-jobs] taeglicherJob: Shop ${f.$id} nicht vollständig gelöscht: ${fehlerText(err)}`) + } + } +} + +/** + * Tagesjob: Kaeuferdaten bezahlter Bestellungen und Widerrufe 3 Jahre nach Ende des Bestelljahres anonymisieren, unbezahlte/ + * stornierte Bestellungen nach 30 Tagen loeschen, Shops, die seit 90 Tagen abgeschaltet sind, samt Bildern loeschen. Je Lauf hoechstens + * config.shop.loeschDeckel (Standard 2000) Loeschungen und ebenso viele Anonymisierungen; der Rest folgt am naechsten Tag. Jeder Schritt + * und jeder Shop ist abgesichert (Fehler werden gezaehlt und ohne Personendaten geloggt). Bei SHOP_ENABLED=off laeuft der Job nur, wenn + * es noch Shop-Dokumente gibt (die Fristen gelten auch dann). + * @param {Date} [now] + * @returns {Promise} { aus } | { uebersprungen } | { fehler: true } | Zaehler { bestellungenAnonymisiert, widerrufeAnonymisiert, + * unbezahltGeloescht, shopsGeloescht, shopDokumenteGeloescht, fehler, gedeckelt } + */ +export async function taeglicherJob(now = new Date()) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('taeglicherJob: now fehlt') + return einzeln('taeglicherJob', async () => { + if (shopModus() === 'off' && !(await gibtShops())) return { aus: true } + const lauf = { + jetzt: now, + loeschRest: deckel(), + anonymRest: deckel(), + gedeckelt: false, + fehler: 0, + zaehler: { bestellungenAnonymisiert: 0, widerrufeAnonymisiert: 0, unbezahltGeloescht: 0, shopsGeloescht: 0, shopDokumenteGeloescht: 0 }, + } + const grenze = anonymisierGrenze(now) + let shops = [] + await schritt(lauf, 'Shops lesen', async () => { + shops = await listAllDocuments(config.collections.shopConfig, [Query.select(['$id'])]) + }) + for (const shop of shops) { + await schritt(lauf, 'unbezahlte Bestellungen', () => loescheUnbezahlte(lauf, shop.$id, now)) + await schritt(lauf, 'Bestellungen anonymisieren', () => anonymisiereBestellungen(lauf, shop.$id, grenze)) + await schritt(lauf, 'Widerrufe anonymisieren', () => anonymisiereWiderrufe(lauf, shop.$id, grenze)) + } + await schritt(lauf, 'abgeschaltete Shops', () => loescheAbgeschaltete(lauf, now)) + return { ...lauf.zaehler, fehler: lauf.fehler, gedeckelt: lauf.gedeckelt } + }) +} diff --git a/server/services/shop/kundenLoeschung.js b/server/services/shop/kundenLoeschung.js new file mode 100644 index 0000000..90918c8 --- /dev/null +++ b/server/services/shop/kundenLoeschung.js @@ -0,0 +1,26 @@ +import { config } from '../../config.js' +import { listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { leereCache } from './kontext.js' + +/** + * Kundenloeschung (Plan 2026-10-05-webshop.md, J3): schliesst alle Shops des Kunden (shopConfig.geoeffnet=false) und leert den + * Speicher der oeffentlichen Schnittstelle. Die Funktion selbst schaltet disableCustomerFeatures ab; die Daten loescht der Tagesjob + * (shop/jobs.js) 90 Tage nach dem Abschalten. Scheitert ein Shop, werden die uebrigen trotzdem geschlossen und der erste Fehler weitergereicht. + * @returns {Promise} Zahl der geschlossenen Shops + */ +export async function schliesseShopsVonKunde(customerId) { + const shops = await listAllDocuments(config.collections.shopConfig, [Query.equal('customerId', customerId)]) + let geschlossen = 0 + let erster = null + for (const shop of shops) { + try { + if (shop.geoeffnet !== false) await updateDocument(config.collections.shopConfig, shop.$id, { geoeffnet: false }) + geschlossen += 1 + } catch (err) { + erster ||= err + } + leereCache(shop.siteKey || undefined) + } + if (erster) throw erster + return geschlossen +} diff --git a/server/services/shop/loeschen.js b/server/services/shop/loeschen.js new file mode 100644 index 0000000..b2d5a3b --- /dev/null +++ b/server/services/shop/loeschen.js @@ -0,0 +1,60 @@ +import { config } from '../../config.js' +import { deleteDocument, listDocuments, Query } from '../appwriteAdmin.js' +import { loescheAlleBilder } from './bilder.js' + +/** + * Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, Artikelbilder und zuletzt das + * shopConfig-Dokument. Gemeinsamer Baustein des Tagesjobs (Shop seit 90 Tagen abgeschaltet, jobs.js) und der Loeschfristen + * (retention.js, Kunde faellig). Bewusst ohne Import von bestellungen.js/Stripe: retention.js darf nichts Schweres nachladen. + * Das Stripe-Konto gehoert dem Haendler und wird nie angefasst. + * + * Seitenweise (100 je Abfrage, immer die erste Seite neu, denn gelesene Dokumente sind danach weg). Am Deckel endet der Lauf + * (vollstaendig: false), der naechste setzt fort. Ein Fehler beim Loeschen wirft (sonst wuerde dieselbe Seite endlos neu gelesen). + */ + +const SEITE = 100 +const GRUPPEN = [ + ['shopOrders', 'bestellungen'], + ['shopProducts', 'artikel'], + ['shopWiderrufe', 'widerrufe'], +] + +/** + * @param {{ shopId: string, siteKey?: string, deckel?: number, nochGueltig?: () => Promise }} opt + * deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe); nochGueltig wird vor jeder Seite gefragt (false = abbrechen) + * @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>} + * vollstaendig = alle Dokumente, Bilder und das shopConfig-Dokument sind weg + */ +export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) { + if (typeof shopId !== 'string' || shopId === '') throw new TypeError('loescheShopDaten: shopId fehlt') + const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false } + for (const [name, feld] of GRUPPEN) { + const sammlung = config.collections[name] + for (;;) { + if (!(await nochGueltig())) return { ...erg, abgebrochen: true } + const rest = deckel - erg.geloescht + if (rest <= 0) return erg + const seite = await listDocuments(sammlung, [Query.equal('shopId', shopId), Query.limit(Math.min(SEITE, rest))]) + if (seite.length === 0) break + for (const doc of seite) { + try { + await deleteDocument(sammlung, doc.$id) + } catch (err) { + if (err?.status !== 404) throw err + } + erg[feld] += 1 + erg.geloescht += 1 + } + } + } + if (!(await nochGueltig())) return { ...erg, abgebrochen: true } + if (siteKey) await loescheAlleBilder(siteKey) + try { + await deleteDocument(config.collections.shopConfig, shopId) + erg.konfig = true + } catch (err) { + if (err?.status !== 404) throw err + } + erg.vollstaendig = true + return erg +} diff --git a/test/retention.test.js b/test/retention.test.js index a81ff57..d4d8998 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -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')) +}) diff --git a/test/shopJobs.test.js b/test/shopJobs.test.js new file mode 100644 index 0000000..8e447f9 --- /dev/null +++ b/test/shopJobs.test.js @@ -0,0 +1,452 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Webshop J3: Tagesjob (Anonymisieren, Unbezahltes loeschen, abgeschaltete Shops loeschen), Stundenjob, Kundenloeschung, Registrierung. +// Alles gegen den Appwrite-Nachbau und einen temporaeren Upload-Ordner; die Uhr ist immer ein fester Zeitpunkt. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + shopWiderrufe: schemaKeys('shopWiderrufe'), + }, + datetimes: SHOP_DATETIMES, + defaultLimit: 25, +}) +const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'shop-jobs-')) +process.env.SHOP_UPLOAD_DIR = uploadDir +after(() => { + rmSync(uploadDir, { recursive: true, force: true }) + return aw.close() +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument, getDocument } = await import('../server/services/appwriteAdmin.js') +const { taeglicherJob, stuendlicherJob, minutenJob, anonymisierGrenze } = await import('../server/services/shop/jobs.js') +const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const sf = await import('../server/services/siteFeatures.js') + +const KEY = 'abcdefghijklmnop' +const KEY2 = 'bcdefghijklmnopq' +const TAG = 24 * 3600 * 1000 +const JETZT = new Date('2026-10-05T10:00:00.000Z') +const vor = (tage, von = JETZT) => new Date(von.getTime() - tage * TAG).toISOString() + +let n = 0 +const id = (prefix) => `${prefix}${++n}` +const shop = (shopId, over = {}) => aw.seed('shopConfig', { $id: shopId, customerId: 'cs1', projectId: 'p1', siteKey: KEY, geoeffnet: true, ...over }) +const feature = (shopId, over = {}) => + aw.seed('portalFeatures', { $id: shopId, customerId: 'cs1', projectId: 'p1', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true, ...over }) +const PERSON = { + kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' }, + lieferadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' }, + rechnungsadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' }, +} +const bestellung = (shopId, over = {}, daten = {}) => { + const doc = { + $id: id('o'), + shopId, + customerId: 'cs1', + bestellnummer: '26-ABC123', + status: 'versendet', + summeCent: 2500, + erstelltAt: vor(10), + bezahltAt: vor(10), + kaeuferEmail: 'max@example.com', + bestellung: JSON.stringify({ positionen: [{ produktId: 'a', name: 'Tasse', menge: 2, einzelCent: 1000 }], summen: { gesamtCent: 2500 }, ...PERSON, ...daten }), + ...over, + } + aw.seed('shopOrders', doc) + return doc.$id +} +const widerruf = (shopId, over = {}) => { + const doc = { $id: id('w'), shopId, bestellnummer: '26-ABC123', name: 'Max Mustermann', email: 'max@example.com', nachricht: 'Ich widerrufe.', eingangAt: vor(10), ...over } + aw.seed('shopWiderrufe', doc) + return doc.$id +} +const produkt = (shopId) => { + const doc = { $id: id('pr'), shopId, customerId: 'cs1', aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' } + aw.seed('shopProducts', doc) + return doc.$id +} +const bild = (siteKey, bildId = id('img')) => { + const ordner = path.join(uploadDir, siteKey) + mkdirSync(ordner, { recursive: true }) + writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x') + writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x') + writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed: true, createdAt: vor(400) })) + return bildId +} +const order = (orderId) => aw.docs('shopOrders').get(orderId) +const daten = (orderId) => JSON.parse(order(orderId).bestellung) + +beforeEach(async () => { + for (const name of ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'portalFeatures']) { + for (const docId of aw.docs(name).keys()) await deleteDocument(config.collections[name], docId) + } + rmSync(uploadDir, { recursive: true, force: true }) + mkdirSync(uploadDir, { recursive: true }) + config.shop.mode = 'on' + config.shop.loeschDeckel = undefined +}) + +// ------------------------------------------------------------------------------------------------ Anonymisieren + +test('anonymisierGrenze: 1. Januar Berlin, drei Jahre nach Ende des Bestelljahres', () => { + // Bestelljahr 2026 -> Ende 31.12.2026 -> +3 Jahre = 01.01.2030 (Berlin) + assert.equal(anonymisierGrenze(new Date('2029-12-31T22:59:59.999Z')).toISOString(), '2025-12-31T23:00:00.000Z', 'noch 2029 in Berlin: Grenze = 01.01.2026 (Bestelljahr 2026 ist noch nicht dran)') + assert.equal(anonymisierGrenze(new Date('2029-12-31T23:00:00.000Z')).toISOString(), '2026-12-31T23:00:00.000Z', 'ab 01.01.2030 00:00 Berlin: Grenze = 01.01.2027 (Bestelljahr 2026 ist dran)') +}) + +test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, nicht eine Sekunde vorher', async () => { + shop('s_a') + const o = bestellung('s_a', { erstelltAt: '2026-06-01T10:00:00.000Z' }) + const vorher = await taeglicherJob(new Date('2029-12-31T22:59:59.000Z')) // 23:59:59 Berlin + assert.equal(vorher.bestellungenAnonymisiert, 0) + assert.equal(order(o).anonymisiertAt, undefined) + assert.equal(order(o).kaeuferEmail, 'max@example.com') + + const nachher = await taeglicherJob(new Date('2029-12-31T23:00:00.000Z')) // 00:00 Berlin, 1.1.2030 + assert.equal(nachher.bestellungenAnonymisiert, 1) + assert.equal(nachher.fehler, 0) + const doc = order(o) + assert.equal(doc.kaeuferEmail, '') + assert.ok(doc.anonymisiertAt) + const d = daten(o) + assert.deepEqual(d.kaeufer, { name: '', email: '', telefon: '' }) + assert.equal('lieferadresse' in d, false) + assert.equal('rechnungsadresse' in d, false) + assert.equal(d.positionen[0].name, 'Tasse', 'Positionen bleiben') + assert.equal(d.summen.gesamtCent, 2500, 'Betraege bleiben') + assert.equal(doc.bestellnummer, '26-ABC123') + assert.equal(doc.status, 'versendet') + assert.equal(doc.summeCent, 2500) + assert.ok(!JSON.stringify(doc).includes('Mustermann')) +}) + +test('Silvester-Nacht: eine Bestellung vom 31.12.2026 23:30 UTC gehoert in Berlin zu 2027 und folgt erst 2031', async () => { + shop('s_b') + const o = bestellung('s_b', { erstelltAt: '2026-12-31T23:30:00.000Z' }) + await taeglicherJob(new Date('2030-06-01T10:00:00.000Z')) + assert.equal(order(o).anonymisiertAt, undefined, '2027 ist 2030 noch nicht dran') + await taeglicherJob(new Date('2031-01-01T10:00:00.000Z')) + assert.ok(order(o).anonymisiertAt) +}) + +test('Anonymisierung ist idempotent und fasst andere Shops und junge Bestellungen nicht an', async () => { + shop('s_c') + shop('s_d', { siteKey: KEY2 }) + const alt = bestellung('s_c', { erstelltAt: '2020-03-01T10:00:00.000Z' }) + const jung = bestellung('s_c', { erstelltAt: '2026-03-01T10:00:00.000Z' }) + const erste = await taeglicherJob(JETZT) + assert.equal(erste.bestellungenAnonymisiert, 1) + const stand = order(alt).anonymisiertAt + const zweite = await taeglicherJob(new Date(JETZT.getTime() + TAG)) + assert.equal(zweite.bestellungenAnonymisiert, 0) + assert.equal(order(alt).anonymisiertAt, stand) + assert.equal(order(jung).kaeuferEmail, 'max@example.com') + assert.equal(daten(jung).kaeufer.name, 'Max Mustermann') +}) + +test('Widerrufe: Name, E-Mail und Nachricht werden nach derselben Frist (eingangAt) geleert, die Bestellnummer bleibt', async () => { + shop('s_e') + const alt = widerruf('s_e', { eingangAt: '2026-02-01T10:00:00.000Z' }) + const jung = widerruf('s_e', { eingangAt: '2027-02-01T10:00:00.000Z' }) + assert.equal((await taeglicherJob(new Date('2029-12-31T22:59:59.000Z'))).widerrufeAnonymisiert, 0) + const erg = await taeglicherJob(new Date('2030-01-01T10:00:00.000Z')) + assert.equal(erg.widerrufeAnonymisiert, 1) + const a = aw.docs('shopWiderrufe').get(alt) + assert.deepEqual([a.name, a.email, a.nachricht, a.bestellnummer], ['', '', '', '26-ABC123']) + assert.equal(aw.docs('shopWiderrufe').get(jung).email, 'max@example.com') + assert.equal((await taeglicherJob(new Date('2030-01-02T10:00:00.000Z'))).widerrufeAnonymisiert, 0, 'idempotent') +}) + +// ------------------------------------------------------------------------------------------------ Unbezahltes + +test('unbezahlte Bestellungen: storniert und alte offene nach 30 Tagen weg; bezahlte, junge und solche mit bezahltAt bleiben', async () => { + shop('s_f') + const storniertAlt = bestellung('s_f', { status: 'storniert', erstelltAt: vor(31), bezahltAt: null }) + const storniertJung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(29), bezahltAt: null }) + const offenAlt = bestellung('s_f', { status: 'offen', erstelltAt: vor(40), bezahltAt: null }) + const offenJung = bestellung('s_f', { status: 'offen', erstelltAt: vor(2), bezahltAt: null }) + const bezahltAlt = bestellung('s_f', { status: 'versendet', erstelltAt: vor(100) }) + const stornoMitZahlung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(100), bezahltAt: vor(100) }) + const erg = await taeglicherJob(JETZT) + assert.equal(erg.unbezahltGeloescht, 2) + assert.equal(erg.fehler, 0) + for (const weg of [storniertAlt, offenAlt]) assert.equal(aw.docs('shopOrders').has(weg), false) + for (const da of [storniertJung, offenJung, bezahltAlt, stornoMitZahlung]) assert.equal(aw.docs('shopOrders').has(da), true) + assert.equal((await taeglicherJob(JETZT)).unbezahltGeloescht, 0) +}) + +// ------------------------------------------------------------------------------------------------ Abgeschaltete Shops + +test('Shop seit 91 Tagen disabled: Artikel, Bestellungen, Widerrufe, Bilder und shopConfig weg; Funktionsdokument bleibt disabled mit Vermerk', async () => { + shop('s_g') + feature('s_g', { state: 'disabled', enabled: false, disabledAt: vor(91), disabledReason: 'team', adminNote: 'war aus' }) + const p = produkt('s_g') + const o = bestellung('s_g') + const w = widerruf('s_g') + const b = bild(KEY) + // anderer Shop: aktiv, und einer erst 30 Tage disabled: bleiben unberuehrt + shop('s_h', { siteKey: KEY2 }) + feature('s_h', { siteKey: KEY2, projectId: 'p2' }) + const o2 = bestellung('s_h') + const bild2 = bild(KEY2) + shop('s_i', { siteKey: 'cdefghijklmnopqr' }) + feature('s_i', { siteKey: 'cdefghijklmnopqr', projectId: 'p3', state: 'disabled', disabledAt: vor(30) }) + const o3 = bestellung('s_i') + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.shopsGeloescht, 1) + assert.equal(erg.shopDokumenteGeloescht, 3) + assert.equal(erg.fehler, 0) + assert.equal(aw.docs('shopProducts').has(p), false) + assert.equal(aw.docs('shopOrders').has(o), false) + assert.equal(aw.docs('shopWiderrufe').has(w), false) + assert.equal(aw.docs('shopConfig').has('s_g'), false) + assert.equal(existsSync(path.join(uploadDir, KEY)), false) + assert.equal(existsSync(path.join(uploadDir, KEY, `${b}-l.webp`)), false) + const f = aw.docs('portalFeatures').get('s_g') + assert.equal(f.state, 'disabled') + assert.equal(f.adminNote, 'Shopdaten gelöscht am 2026-10-05') + assert.ok(f.adminNote.length <= 300) + assert.equal(aw.docs('shopOrders').has(o2), true) + assert.equal(aw.docs('shopConfig').has('s_h'), true) + assert.equal(existsSync(path.join(uploadDir, KEY2, `${bild2}-l.webp`)), true) + assert.equal(aw.docs('shopOrders').has(o3), true, '30 Tage reichen nicht') + assert.equal(aw.docs('shopConfig').has('s_i'), true) + + // zweiter Lauf: nichts mehr zu tun, der Vermerk haelt ihn kurz + aw.resetListCalls() + const nochmal = await taeglicherJob(new Date(JETZT.getTime() + TAG)) + assert.equal(nochmal.shopsGeloescht, 0) + assert.equal(aw.listCalls('shopProducts').length, 0) +}) + +test('Shop ohne siteKey-Bilder und ohne Dokumente: shopConfig wird trotzdem entfernt; wieder eingeschaltete Shops bleiben', async () => { + shop('s_j') + feature('s_j', { state: 'disabled', disabledAt: vor(100) }) + shop('s_k', { siteKey: KEY2 }) + feature('s_k', { siteKey: KEY2, state: 'active', disabledAt: null }) + const o = bestellung('s_k') + const erg = await taeglicherJob(JETZT) + assert.equal(erg.shopsGeloescht, 1) + assert.equal(aw.docs('shopConfig').has('s_j'), false) + assert.equal(aw.docs('shopOrders').has(o), true) +}) + +test('Deckel: je Lauf hoechstens config.shop.loeschDeckel Loeschungen, der Rest folgt an den naechsten Tagen', async () => { + config.shop.loeschDeckel = 3 + shop('s_l') + feature('s_l', { state: 'disabled', disabledAt: vor(120) }) + const ids = { o: [], w: [], p: [] } + for (let i = 0; i < 4; i++) ids.o.push(bestellung('s_l')) + for (let i = 0; i < 2; i++) ids.w.push(widerruf('s_l')) + ids.p.push(produkt('s_l')) + bild(KEY) + const gesamt = () => aw.docs('shopOrders').size + aw.docs('shopWiderrufe').size + aw.docs('shopProducts').size + assert.equal(gesamt(), 7) + + const tag1 = await taeglicherJob(JETZT) + assert.equal(tag1.gedeckelt, true) + assert.equal(tag1.shopsGeloescht, 0) + assert.equal(gesamt(), 4) + assert.equal(aw.docs('shopConfig').has('s_l'), true, 'shopConfig bleibt, bis alles weg ist') + assert.equal(existsSync(path.join(uploadDir, KEY)), true, 'Bilder auch') + const tag2 = await taeglicherJob(new Date(JETZT.getTime() + TAG)) + assert.equal(gesamt(), 1) + assert.equal(tag2.shopsGeloescht, 0) + const tag3 = await taeglicherJob(new Date(JETZT.getTime() + 2 * TAG)) + assert.equal(gesamt(), 0) + assert.equal(tag3.shopsGeloescht, 1) + assert.equal(aw.docs('shopConfig').has('s_l'), false) + assert.equal(existsSync(path.join(uploadDir, KEY)), false) + assert.equal(tag3.gedeckelt, false) +}) + +test('Deckel gilt auch fuer unbezahlte und anonymisierte Bestellungen', async () => { + config.shop.loeschDeckel = 2 + shop('s_m') + for (let i = 0; i < 5; i++) bestellung('s_m', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null }) + for (let i = 0; i < 3; i++) bestellung('s_m', { erstelltAt: '2020-01-01T10:00:00.000Z' }) + const tag1 = await taeglicherJob(JETZT) + assert.equal(tag1.unbezahltGeloescht, 2) + assert.equal(tag1.bestellungenAnonymisiert, 2) + assert.equal(tag1.gedeckelt, true) + let t = 1 + let letzte = tag1 + while (letzte.gedeckelt && t < 10) letzte = await taeglicherJob(new Date(JETZT.getTime() + t++ * TAG)) + assert.equal([...aw.docs('shopOrders').values()].filter((d) => d.status === 'storniert').length, 0) + assert.equal([...aw.docs('shopOrders').values()].filter((d) => !d.anonymisiertAt).length, 0) +}) + +// ------------------------------------------------------------------------------------------------ Schalter, Fehler + +test('SHOP_ENABLED=off ohne Shop-Dokumente: der Tagesjob kehrt nach einer Abfrage zurueck; mit Shop-Dokumenten laeuft er (Fristen gelten weiter)', async () => { + config.shop.mode = 'off' + aw.resetListCalls() + assert.deepEqual(await taeglicherJob(JETZT), { aus: true }) + assert.equal(aw.listCalls().length, 1, 'genau eine Abfrage') + assert.equal(aw.listCalls()[0].collection, 'shopConfig') + assert.deepEqual(await stuendlicherJob(JETZT), { aus: true }) + assert.deepEqual(await minutenJob(JETZT), { aus: true }) + + shop('s_n') + const o = bestellung('s_n', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null }) + const erg = await taeglicherJob(JETZT) + assert.equal(erg.unbezahltGeloescht, 1) + assert.equal(aw.docs('shopOrders').has(o), false) +}) + +test('Fehler sind je Schritt und je Dokument abgesichert: ein kaputter Widerruf stoppt weder Bestellungen noch andere Shops', async (t) => { + const warn = t.mock.method(console, 'warn', () => {}) + shop('s_o') + shop('s_p', { siteKey: KEY2 }) + const w1 = widerruf('s_o', { eingangAt: '2020-01-01T10:00:00.000Z' }) + const w2 = widerruf('s_o', { eingangAt: '2020-01-02T10:00:00.000Z' }) + const o = bestellung('s_o', { erstelltAt: '2020-01-01T10:00:00.000Z' }) + const w3 = widerruf('s_p', { eingangAt: '2020-01-01T10:00:00.000Z' }) + aw.failNext({ collection: 'shopWiderrufe', method: 'PATCH', status: 500, message: 'Fehler fuer max@example.com' }) + const erg = await taeglicherJob(JETZT) + assert.equal(erg.fehler, 1) + assert.equal(erg.widerrufeAnonymisiert, 2) + assert.ok(order(o).anonymisiertAt) + const geleert = [w1, w2, w3].filter((x) => !aw.docs('shopWiderrufe').get(x).email) + assert.equal(geleert.length, 2) + const logs = warn.mock.calls.map((c) => String(c.arguments[0])).join('\n') + assert.match(logs, /taeglicherJob: shopWiderrufe .* fehlgeschlagen/) + assert.ok(!logs.includes('Mustermann'), 'keine Kaeuferdaten im Log') + // der naechste Lauf holt das kaputte Dokument nach + assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).widerrufeAnonymisiert, 1) +}) + +test('Fehler beim Loeschen eines Shops stoppt die anderen nicht und laesst den Vermerk weg', async (t) => { + t.mock.method(console, 'warn', () => {}) + shop('s_q') + feature('s_q', { state: 'disabled', disabledAt: vor(100) }) + bestellung('s_q') + shop('s_r', { siteKey: KEY2 }) + feature('s_r', { siteKey: KEY2, projectId: 'p2', state: 'disabled', disabledAt: vor(100) }) + bestellung('s_r') + aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' }) + const erg = await taeglicherJob(JETZT) + assert.equal(erg.fehler, 1) + assert.equal(erg.shopsGeloescht, 1) + const offen = ['s_q', 's_r'].filter((x) => aw.docs('shopConfig').has(x)) + assert.equal(offen.length, 1) + assert.equal(aw.docs('portalFeatures').get(offen[0]).adminNote, undefined, 'kein Vermerk ohne vollstaendige Loeschung') + assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).shopsGeloescht, 1) + assert.equal(aw.docs('shopConfig').size, 0) +}) + +test('ein Lesefehler beim Auflisten der Shops stoppt die Loeschung abgeschalteter Shops nicht', async (t) => { + t.mock.method(console, 'warn', () => {}) + shop('s_s') + feature('s_s', { state: 'disabled', disabledAt: vor(100) }) + aw.failNext({ collection: 'shopConfig', method: 'GET', status: 500, message: 'kaputt' }) + const erg = await taeglicherJob(JETZT) + assert.equal(erg.fehler, 1) + assert.equal(erg.shopsGeloescht, 1) +}) + +// ------------------------------------------------------------------------------------------------ Stundenjob + +test('Stundenjob raeumt nie beanspruchte Bilder nach 24 h auf', async () => { + const ordner = path.join(uploadDir, KEY) + mkdirSync(ordner, { recursive: true }) + for (const [bildId, claimed] of [['alt1', false], ['alt2', true]]) { + writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x') + writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x') + writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed, createdAt: vor(3) })) + } + const erg = await stuendlicherJob(JETZT) + assert.equal(erg.bilderEntfernt, 1) + assert.equal(existsSync(path.join(ordner, 'alt1.json')), false) + assert.equal(existsSync(path.join(ordner, 'alt2.json')), true) +}) + +// ------------------------------------------------------------------------------------------------ Kundenloeschung + +test('Kunde loeschen: Webshop wird abgeschaltet (Grund kunde_geloescht), geoeffnet=false, Daten bleiben fuer die 90 Tage', async () => { + aw.seed('customers', { $id: 'cs9', name: 'Weg', email: 'weg@example.invalid' }) + aw.seed('websiteProjects', { $id: 'p9', customerId: 'cs9', projectName: 'Laden' }) + aw.seed('shopConfig', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', siteKey: KEY, geoeffnet: true }) + aw.seed('portalFeatures', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true }) + const o = bestellung('f_p9_shop') + aw.seed('shopConfig', { $id: 'f_fremd', customerId: 'cs8', projectId: 'p8', siteKey: KEY2, geoeffnet: true }) + + await deleteCustomerWithPortalAccess('cs9') + + assert.equal(aw.docs('customers').has('cs9'), false) + const f = aw.docs('portalFeatures').get('f_p9_shop') + assert.deepEqual([f.state, f.enabled, f.adminNote, f.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht']) + assert.ok(Math.abs(Date.parse(f.disabledAt) - Date.now()) < 5000) + assert.equal(aw.docs('shopConfig').get('f_p9_shop').geoeffnet, false) + assert.equal(aw.docs('shopConfig').get('f_fremd').geoeffnet, true, 'Shop eines anderen Kunden unberuehrt') + assert.equal(aw.docs('shopOrders').has(o), true, 'Daten bleiben bis zur 90-Tage-Frist') + + // 89 Tage spaeter noch da, nach 91 Tagen vom Tagesjob geloescht + const frueh = new Date(Date.parse(f.disabledAt) + 89 * TAG) + assert.equal((await taeglicherJob(frueh)).shopsGeloescht, 0) + assert.equal(aw.docs('shopOrders').has(o), true) + const spaet = new Date(Date.parse(f.disabledAt) + 91 * TAG) + assert.equal((await taeglicherJob(spaet)).shopsGeloescht, 1) + assert.equal(aw.docs('shopOrders').has(o), false) + assert.equal(aw.docs('shopConfig').has('f_p9_shop'), false) + assert.equal(aw.docs('portalFeatures').get('f_p9_shop').state, 'disabled') +}) + +const kundeMitShop = (kunde, projekt) => { + aw.seed('customers', { $id: kunde, name: 'Weg', email: `${kunde}@example.invalid` }) + aw.seed('websiteProjects', { $id: projekt, customerId: kunde, projectName: 'Laden' }) + aw.seed('shopConfig', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, siteKey: KEY, geoeffnet: true }) + aw.seed('portalFeatures', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true }) +} + +test('Kunde loeschen gelingt auch, wenn das Abschalten des Webshops scheitert (Fehler im Log); der Shop wird trotzdem geschlossen', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + kundeMitShop('cs10', 'p10') + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + await deleteCustomerWithPortalAccess('cs10') + assert.equal(aw.docs('customers').has('cs10'), false) + assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs10 nicht abgeschaltet/.test(String(c.arguments[0])))) + assert.equal((await sf.getFeatureDoc('cs10', 'p10', 'shop')).state, 'active', 'das Dokument bleibt wie es war') + assert.equal(aw.docs('shopConfig').get('f_p10_shop').geoeffnet, false) +}) + +test('Kunde loeschen gelingt auch, wenn das Schliessen des Webshops scheitert (Fehler im Log); die Funktion ist abgeschaltet', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + kundeMitShop('cs11', 'p11') + aw.failNext({ collection: 'shopConfig', method: 'PATCH', status: 500, message: 'kaputt' }) + await deleteCustomerWithPortalAccess('cs11') + assert.equal(aw.docs('customers').has('cs11'), false) + assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs11 nicht geschlossen/.test(String(c.arguments[0])))) + assert.equal((await sf.getFeatureDoc('cs11', 'p11', 'shop')).state, 'disabled') +}) + +// ------------------------------------------------------------------------------------------------ Registrierung + +test('server/index.js registriert Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) mit unref', () => { + const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + assert.match(quelle, /from '\.\/services\/shop\/jobs\.js'/) + for (const [name, sekunden, takt] of [['shopMinuten', 75, '60 \\* 1000'], ['shopStunden', 130, '60 \\* 60 \\* 1000'], ['shopTages', 200, '24 \\* 60 \\* 60 \\* 1000']]) { + assert.match(quelle, new RegExp(`setTimeout\\(\\(\\) => \\{\\s*${name}\\(\\)\\s*setInterval\\(${name}, ${takt}\\)\\.unref\\(\\)\\s*\\}, ${sekunden} \\* 1000\\)\\.unref\\(\\)`), name) + } +}) + +test('Dokument bleibt lesbar: getDocument liefert das Funktionsdokument nach der Loeschung', async () => { + shop('s_z') + feature('s_z', { state: 'disabled', disabledAt: vor(100) }) + await taeglicherJob(JETZT) + assert.equal((await getDocument(config.collections.portalFeatures, 's_z')).state, 'disabled') +})