schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist (Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
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: 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.
|
|
*/
|
|
|
|
const laufend = new Set()
|
|
|
|
async function einzeln(name, arbeit) {
|
|
if (laufend.has(name)) {
|
|
console.warn(`[shop-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
|
|
return { uebersprungen: true }
|
|
}
|
|
laufend.add(name)
|
|
try {
|
|
return await arbeit()
|
|
} catch (err) {
|
|
console.error(`[shop-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`)
|
|
return { fehler: true }
|
|
} finally {
|
|
laufend.delete(name)
|
|
}
|
|
}
|
|
|
|
/** Minutenjob: offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (bezahlt abschliessen oder verwerfen). Bei SHOP_ENABLED=off aus. */
|
|
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
|
|
delete daten.ipHash
|
|
daten.kaeufer = { name: '', email: '' }
|
|
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 }
|
|
})
|
|
}
|