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:
1 parent
73820fb1e6
commit
b8055b336c
8 files changed
+941
-8
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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<object>} { 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 }
|
||||
})
|
||||
}
|
||||
@@ -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<number>} 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
|
||||
}
|
||||
@@ -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<boolean> }} 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
|
||||
}
|
||||
Reference in new issue
Block a user