Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
+140
-34
@@ -1,8 +1,9 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../config.js'
|
||||
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.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'
|
||||
@@ -21,6 +22,17 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
||||
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
|
||||
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
|
||||
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
|
||||
* - Funktionsbestellungen: Die Zustimmungsnachweise zu bestellten Funktionen (featureConsents, Datenschutzerklaerung Abschnitt 7:
|
||||
* "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet") loescht derselbe Lauf fuer faellige
|
||||
* Kunden mit (plan.news[].consentIds). Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig; ihre Nachweise bleiben, wie ihre
|
||||
* Beitraege, ausserhalb dieser Frist.
|
||||
* - 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, Archiv der bestaetigten Rechtstexte, 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).
|
||||
* - Oeffnungszeiten live: Zeigt der Plan Funktionen 'zeiten' (plan.news[].zeitenIds, Teilmenge von featureIds), loescht der Lauf vor dem
|
||||
* Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures).
|
||||
* Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter
|
||||
@@ -36,6 +48,14 @@ const EVENTS = () => config.collections.contractEvents
|
||||
const TAKEOVER = () => config.collections.takeoverCredits
|
||||
const NEWS_POSTS = () => config.collections.newsPosts
|
||||
const FEATURES = () => config.collections.portalFeatures
|
||||
const CONSENTS = () => config.collections.featureConsents
|
||||
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']
|
||||
const BLANK_FEATURE_FIELDS = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }
|
||||
const hasConsentFields = (doc) => FEATURE_CONSENT_FIELDS.some((key) => doc[key])
|
||||
|
||||
export const RETENTION_YEARS = 3
|
||||
export const RECEIPT_YEARS = 8
|
||||
@@ -69,9 +89,22 @@ 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[] }[],
|
||||
* news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[],
|
||||
* featureFieldIds: string[] }[],
|
||||
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
|
||||
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
|
||||
*/
|
||||
@@ -115,12 +148,20 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
|
||||
const news = []
|
||||
for (const { customerId } of customers) {
|
||||
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
|
||||
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
|
||||
if (!posts.length && !features.length) continue
|
||||
const allFeatures = await list(FEATURES(), [Query.equal('customerId', customerId)])
|
||||
const features = allFeatures.filter((f) => f.state !== 'disabled')
|
||||
// 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)])
|
||||
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 } : {}),
|
||||
zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id),
|
||||
})
|
||||
}
|
||||
@@ -166,18 +207,24 @@ async function deleteQuietly(collection, id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
|
||||
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
||||
* Faellige Daten loeschen. Reihenfolge je Kunde: erst die abhaengigen Daten (News-Beitraege samt Fotos, Funktionen abschalten,
|
||||
* Zustimmungsnachweise der Funktionsbestellungen), dann die Stripe-Metadaten (Abo-ID steht noch im Plan) und zuletzt die
|
||||
* Appwrite-Dokumente der Abos (Vertragsereignisse, dann subscriptions). Grund: findDueDeletions leitet faellige Kunden allein aus den
|
||||
* Abo-Dokumenten ab. Schlaegt ein Loeschen abhaengiger Daten fehl (oder das eines Vertragsereignisses), bleiben die Abo-Dokumente des
|
||||
* Kunden stehen (result.held), der naechste Lauf findet den Kunden wieder und versucht es erneut; sonst blieben die uebrig gebliebenen
|
||||
* 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, zeiten: number },
|
||||
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number,
|
||||
* zeiten: number },
|
||||
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
||||
* failed: { what: string, id: 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, zeiten: 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, zeiten: 0 }
|
||||
const manual = []
|
||||
const failed = []
|
||||
const held = []
|
||||
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
|
||||
const attempt = async (what, id, fn) => {
|
||||
try {
|
||||
@@ -188,30 +235,10 @@ export async function applyDeletions(plan) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const customer of plan.customers || []) {
|
||||
for (const id of customer.subscriptionIds) {
|
||||
try {
|
||||
await getStripe().subscriptions.update(id, { metadata: clear })
|
||||
deleted.stripeCleared += 1
|
||||
} catch (err) {
|
||||
manual.push({
|
||||
customerId: customer.customerId,
|
||||
subscriptionId: id,
|
||||
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const id of customer.eventIds) {
|
||||
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
|
||||
}
|
||||
for (const id of customer.subscriptionIds) {
|
||||
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of plan.news || []) {
|
||||
// News-Beitraege, Funktionen und Zustimmungsnachweise eines Kunden (Eintrag aus plan.news)
|
||||
const deleteDependents = async (entry) => {
|
||||
for (const post of entry.posts) {
|
||||
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
|
||||
// erst das Bild (Dokument bleibt bei Fehler), dann der Beitrag
|
||||
if (post.imageId) {
|
||||
const removed = await attempt('newsImages', post.imageId, async () => {
|
||||
await deleteNewsImage(post.projectId, post.imageId)
|
||||
@@ -242,8 +269,76 @@ export async function applyDeletions(plan) {
|
||||
})
|
||||
if (off) deleted.features += 1
|
||||
}
|
||||
// IP, Browser, Text und Zeit der Bestellung am Funktionsdokument (auch am abgeschalteten): das Dokument bleibt, die Angaben gehen
|
||||
for (const id of entry.featureFieldIds || []) {
|
||||
const blanked = await attempt('portalFeatures', id, async () => {
|
||||
try {
|
||||
await updateDocument(FEATURES(), id, BLANK_FEATURE_FIELDS)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (isGone(err)) return false
|
||||
throw err
|
||||
}
|
||||
})
|
||||
if (blanked) deleted.featureFields += 1
|
||||
}
|
||||
// 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, Rechtstexte-Archiv, 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]))
|
||||
for (const customer of plan.customers || []) {
|
||||
const failedBefore = failed.length
|
||||
const entry = newsOf.get(customer.customerId)
|
||||
if (entry) {
|
||||
newsOf.delete(customer.customerId)
|
||||
await deleteDependents(entry)
|
||||
}
|
||||
if (failed.length > failedBefore) {
|
||||
held.push(customer.customerId)
|
||||
continue
|
||||
}
|
||||
for (const id of customer.subscriptionIds) {
|
||||
try {
|
||||
await getStripe().subscriptions.update(id, { metadata: clear })
|
||||
deleted.stripeCleared += 1
|
||||
} catch (err) {
|
||||
manual.push({
|
||||
customerId: customer.customerId,
|
||||
subscriptionId: id,
|
||||
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const id of customer.eventIds) {
|
||||
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
|
||||
}
|
||||
if (failed.length > failedBefore) {
|
||||
// ein Vertragsereignis ist stehen geblieben: das Abo-Dokument bleibt, sonst wuerde das Ereignis zur Waise
|
||||
held.push(customer.customerId)
|
||||
continue
|
||||
}
|
||||
for (const id of customer.subscriptionIds) {
|
||||
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
|
||||
}
|
||||
}
|
||||
// Eintraege ohne Kunden-Eintrag im Plan (kein Abo-Dokument, das zu halten waere)
|
||||
for (const entry of newsOf.values()) await deleteDependents(entry)
|
||||
|
||||
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
|
||||
const removed = await attempt('takeoverCredits', item.id, async () => {
|
||||
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
||||
@@ -252,7 +347,7 @@ export async function applyDeletions(plan) {
|
||||
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
|
||||
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
|
||||
}
|
||||
return { deleted, manual, failed }
|
||||
return { deleted, manual, failed, held }
|
||||
}
|
||||
|
||||
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
|
||||
@@ -268,8 +363,13 @@ export function formatReport(plan, result = null) {
|
||||
}
|
||||
for (const n of plan.news || []) {
|
||||
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
|
||||
const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : ''
|
||||
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${zeiten}`)
|
||||
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${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}`
|
||||
)
|
||||
}
|
||||
const del = plan.takeover.filter((t) => t.action === 'loeschen')
|
||||
const report = plan.takeover.filter((t) => t.action === 'melden')
|
||||
@@ -286,12 +386,18 @@ export function formatReport(plan, result = null) {
|
||||
lines.push(
|
||||
'',
|
||||
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
|
||||
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
|
||||
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`,
|
||||
`Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`,
|
||||
`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 (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`)
|
||||
if (result.failed?.length) {
|
||||
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
||||
}
|
||||
if (result.held?.length) {
|
||||
lines.push('', 'Abo-Daten bleiben stehen, bis die abhängigen Daten gelöscht sind (der nächste Lauf findet den Kunden wieder):', ...result.held.map((id) => ` Kunde ${id}`))
|
||||
}
|
||||
if (result.manual.length) {
|
||||
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user