diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index eb80902..6c033e1 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) } + // Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind + // disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in + // try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID. + try { + const { disableCustomerFeatures } = await import('./siteFeatures.js') + await disableCustomerFeatures(customerId, 'zeiten', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) + } catch (err) { + console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht abgeschaltet:`, err.message) + } + try { + const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js') + await loescheZeitenVonKunde(customerId) + } catch (err) { + console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht gelöscht:`, 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 30dcec4..eedd222 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -21,6 +21,10 @@ 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. + * - 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 + * "Fehlgeschlagen" im Bericht. * 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 @@ -117,6 +121,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } customerId, posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })), featureIds: features.map((f) => f.$id), + zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id), }) } @@ -165,12 +170,12 @@ async function deleteQuietly(collection, id) { * Appwrite-Dokumente. Lehnt Stripe 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 }, + * newsPosts: number, newsImages: number, features: number, zeiten: number }, * manual: { customerId: string, subscriptionId: string, reason: string }[], * failed: { what: string, id: string, reason: string }[] }>} */ export async function applyDeletions(plan) { - const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, zeiten: 0 } const manual = [] const failed = [] const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) @@ -217,7 +222,19 @@ export async function applyDeletions(plan) { } if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1 } + // Oeffnungszeiten: erst die Daten (Dokumente, Merker, Speicher), dann das Abschalten; scheitert das Loeschen, bleiben diese Funktionen + // unangetastet und der Fehler steht im Bericht (wie beim Bild vor dem News-Beitrag) + const zeitenIds = new Set(entry.zeitenIds || []) + let zeitenWeg = true + if (zeitenIds.size) { + zeitenWeg = await attempt('oeffnungszeiten', entry.customerId, async () => { + const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js') + deleted.zeiten += await loescheZeitenVonKunde(entry.customerId) + return true + }) + } for (const id of entry.featureIds) { + if (!zeitenWeg && zeitenIds.has(id)) continue const off = await attempt('portalFeatures', id, async () => { // disable() meldet bei Analyse auch den Sammeldienst ab (best effort) await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) @@ -251,7 +268,8 @@ export function formatReport(plan, result = null) { } for (const n of plan.news || []) { const images = n.posts.filter((p) => p.imageId).length - 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`) + 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}`) } const del = plan.takeover.filter((t) => t.action === 'loeschen') const report = plan.takeover.filter((t) => t.action === 'melden') @@ -270,6 +288,7 @@ 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.` ) + 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}`)) } diff --git a/test/zeitenLoeschen.test.js b/test/zeitenLoeschen.test.js new file mode 100644 index 0000000..2bbc09a --- /dev/null +++ b/test/zeitenLoeschen.test.js @@ -0,0 +1,244 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Oeffnungszeiten live, Kunde geloescht (Spec 5): deleteCustomerWithPortalAccess schaltet 'zeiten' wie 'termine' ab (Notiz "Kunde gelöscht", +// Grund kunde_geloescht) und loescht Daten und Erinnerungsmerker sofort; die Loeschfristen (retention.js) loeschen die Daten ebenfalls, +// bevor sie die Funktion eines faelligen Kunden abschalten. Fehler halten die Kundenloeschung nie auf. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + takeoverCredits: schemaKeys('takeoverCredits'), + newsPosts: schemaKeys('newsPosts'), + termineConfig: schemaKeys('termineConfig'), + }, + datetimes: ZEITEN_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +after(() => Promise.all([aw.close(), st.close()])) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') +const { oeffentlicheZeitenAntwort } = await import('../server/services/zeiten/oeffentlich.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { findDueDeletions, applyDeletions, formatReport } = await import('../server/services/retention.js') + +const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 +const JAN_2026 = new Date('2026-01-01T12:00:00+01:00') +const MODUS_VORHER = config.zeiten.mode + +const tage = (woche = {}) => { + const t = {} + for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } + return t +} +const eingabe = () => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) + +let nr = 0 +/** + * Kunde mit Projekten und je einer Zeiten-Funktion (state), gespeicherten Zeiten und Erinnerungsmerker; mit news: eine aktive News-Funktion. + * Liefert { customerId, features: [{ $id, projectId, siteKey, ... }] }. + */ +async function legeKunde({ projekte = 1, state = 'active', news = false } = {}) { + nr += 1 + const customerId = `zl${nr}` + aw.seed('customers', { $id: customerId, name: `Salon ${nr}`, companyName: `Salon ${nr} GmbH`, email: `salon${nr}@example.invalid` }) + const features = [] + for (let i = 1; i <= projekte; i += 1) { + const projectId = `zlp${nr}_${i}` + // eindeutiger siteKey je Funktion: 16 Zeichen aus [a-z2-7] + const siteKey = `zeitenkey${'abcdefghijklmnop'[nr]}${'abcdefghijklmnop'[i]}aaaaa` + const feature = { $id: `f_${projectId}_zeiten`, customerId, projectId, featureKey: 'zeiten', state, enabled: state === 'active', siteKey } + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}-${i}` }) + aw.seed('portalFeatures', feature) + await speichereZeiten({ feature, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + aw.seed('zeitenErinnerungen', { $id: `m_${feature.$id}`, featureId: feature.$id, datum: '2026-12-24', createdAt: '2026-12-01T08:00:00.000Z' }) + features.push(feature) + } + if (news) aw.seed('portalFeatures', { $id: `f_${features[0].projectId}_news`, customerId, projectId: features[0].projectId, featureKey: 'news', state: 'active', enabled: true }) + return { customerId, features } +} + +const zeitenDocs = (customerId) => [...aw.docs('oeffnungszeiten').values()].filter((d) => d.customerId === customerId) +const merkerVon = (features) => [...aw.docs('zeitenErinnerungen').values()].filter((m) => features.some((f) => f.$id === m.featureId)) +const featureDoc = (id) => aw.docs('portalFeatures').get(id) + +beforeEach(() => { + config.zeiten.mode = 'on' + resetZeitenCacheForTests() +}) +afterEach(() => { + config.zeiten.mode = MODUS_VORHER + mock.restoreAll() +}) + +// ---------------------------------------------------------------- deleteCustomerWithPortalAccess + +test('Kundenlöschung schaltet zeiten ab und löscht Daten und Merker', async () => { + const kunde = await legeKunde({ projekte: 2, news: true }) + const fremd = await legeKunde() + const schonAus = await legeKunde({ state: 'disabled' }) + const frueher = '2026-09-01T10:00:00.000Z' + aw.seed('portalFeatures', { ...featureDoc(schonAus.features[0].$id), disabledAt: frueher, disabledReason: 'abo_ende' }) + assert.equal(zeitenDocs(kunde.customerId).length, 2) + assert.equal(merkerVon(kunde.features).length, 2) + + await deleteCustomerWithPortalAccess(kunde.customerId) + await deleteCustomerWithPortalAccess(schonAus.customerId) + + assert.equal(aw.docs('customers').has(kunde.customerId), false) + for (const f of kunde.features) { + const doc = featureDoc(f.$id) + assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], f.projectId) + assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, f.projectId) + } + assert.deepEqual(zeitenDocs(kunde.customerId), [], 'Zeiten des Kunden gelöscht') + assert.deepEqual(merkerVon(kunde.features), [], 'Erinnerungsmerker des Kunden gelöscht') + assert.equal(featureDoc(`f_${kunde.features[0].projectId}_news`).state, 'active', 'News ist nicht Sache der Zeiten') + + // ein schon abgeschaltetes Dokument behält den Beginn seiner Frist, seine Daten gehen trotzdem mit dem Kunden + assert.equal(featureDoc(schonAus.features[0].$id).disabledAt, frueher) + assert.equal(featureDoc(schonAus.features[0].$id).disabledReason, 'abo_ende') + assert.deepEqual(zeitenDocs(schonAus.customerId), []) + + // ein anderer Kunde bleibt unberührt + assert.equal(featureDoc(fremd.features[0].$id).state, 'active') + assert.equal(zeitenDocs(fremd.customerId).length, 1) + assert.equal(merkerVon(fremd.features).length, 1) +}) + +test('öffentliche Antwort danach aktiv false', async () => { + const kunde = await legeKunde() + const fremd = await legeKunde() + const [eigene] = kunde.features + assert.equal((await oeffentlicheZeitenAntwort(eigene.siteKey, JETZT)).aktiv, true, 'vorher beantwortet (und im Speicher)') + assert.equal((await oeffentlicheZeitenAntwort(fremd.features[0].siteKey, JETZT)).aktiv, true) + + await deleteCustomerWithPortalAccess(kunde.customerId) + + assert.deepEqual(await oeffentlicheZeitenAntwort(eigene.siteKey, JETZT), { aktiv: false }, 'Speicher geleert, Kunde und Funktion weg') + assert.deepEqual(await oeffentlicheZeitenAntwort(eigene.siteKey, new Date(JETZT.getTime() + 60000)), { aktiv: false }) + assert.equal((await oeffentlicheZeitenAntwort(fremd.features[0].siteKey, JETZT)).aktiv, true, 'ein anderer Kunde antwortet weiter') +}) + +test('Fehler beim Löschen der Zeiten bricht die Kundenlöschung nicht ab', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const kunde = await legeKunde({ projekte: 2 }) + aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'kaputt' }) + + await deleteCustomerWithPortalAccess(kunde.customerId) + + assert.equal(aw.docs('customers').has(kunde.customerId), false, 'der Kunde ist gelöscht') + const zeilen = log.mock.calls.map((c) => c.arguments.map(String).join(' ')) + const zeile = zeilen.find((z) => /Öffnungszeiten von Kunde .* nicht gelöscht/.test(z)) + assert.ok(zeile, 'Logzeile') + assert.ok(zeile.includes(kunde.customerId) && zeile.includes('kaputt')) + assert.ok(!zeile.includes('Salon') && !zeile.includes('example.invalid'), 'keine Kundendaten im Log') + for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'disabled', 'abgeschaltet wurde trotzdem') + assert.equal(zeitenDocs(kunde.customerId).length, 1, 'ein Dokument mit Fehler bleibt, das andere ist weg') + assert.equal(await oeffentlicheZeitenAntwort(kunde.features[0].siteKey, JETZT).then((a) => a.aktiv), false) +}) + +test('Fehler beim Abschalten der Zeiten hält weder das Löschen der Daten noch die Kundenlöschung auf', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const kunde = await legeKunde() + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + + await deleteCustomerWithPortalAccess(kunde.customerId) + + assert.equal(aw.docs('customers').has(kunde.customerId), false) + assert.ok(log.mock.calls.some((c) => /Öffnungszeiten von Kunde .* nicht abgeschaltet/.test(String(c.arguments[0])))) + assert.deepEqual(zeitenDocs(kunde.customerId), [], 'die Daten sind trotzdem weg') + assert.deepEqual(merkerVon(kunde.features), []) +}) + +// ---------------------------------------------------------------- Loeschfristen (retention.js) + +const abo = (id, customerId, fields) => aw.seed('subscriptions', { $id: id, stripeSubscriptionId: id, customerId, ...fields }) + +test('Löschfristen: Daten und Merker der Zeiten eines fälligen Kunden werden gelöscht, dann die Funktion abgeschaltet', async () => { + const faellig = await legeKunde({ projekte: 2, news: true }) + const laufend = await legeKunde() + abo(`sub_${faellig.customerId}`, faellig.customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + abo(`sub_${laufend.customerId}`, laufend.customerId, { status: 'active', endedAt: null }) + + const plan = await findDueDeletions(JAN_2026) + const eintrag = plan.news.find((n) => n.customerId === faellig.customerId) + assert.deepEqual(eintrag.zeitenIds.slice().sort(), faellig.features.map((f) => f.$id).sort()) + assert.equal(eintrag.featureIds.length, 3, 'zwei Zeiten und News') + assert.ok(!plan.news.some((n) => n.customerId === laufend.customerId)) + assert.match(formatReport(plan), new RegExp(`Kunde ${faellig.customerId}: .*3 Funktion\\(en\\) abzuschalten.*Öffnungszeiten`)) + + const result = await applyDeletions(plan) + assert.deepEqual(result.failed, []) + assert.equal(result.deleted.zeiten, 2) + assert.equal(result.deleted.features, 3) + assert.match(formatReport(plan, result), /Öffnungszeiten: 2 Datensatz\/Datensätze gelöscht/) + assert.deepEqual(zeitenDocs(faellig.customerId), []) + assert.deepEqual(merkerVon(faellig.features), []) + for (const f of faellig.features) assert.equal(featureDoc(f.$id).state, 'disabled') + assert.equal(featureDoc(`f_${faellig.features[0].projectId}_news`).state, 'disabled') + + assert.equal(zeitenDocs(laufend.customerId).length, 1, 'laufendes Abo: nichts angefasst') + assert.equal(featureDoc(laufend.features[0].$id).state, 'active') +}) + +test('Löschfristen: scheitert das Löschen der Zeiten, bleibt die Funktion aktiv (Meldung unter Fehlgeschlagen), ein erneuter Lauf holt es nach', async () => { + const kunde = await legeKunde({ projekte: 2 }) + abo(`sub_${kunde.customerId}`, kunde.customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'kaputt' }) + + const plan = await findDueDeletions(JAN_2026) + const erster = await applyDeletions(plan) + assert.equal(erster.failed.length, 1) + assert.equal(erster.failed[0].what, 'oeffnungszeiten') + assert.equal(erster.failed[0].id, kunde.customerId) + assert.match(erster.failed[0].reason, /kaputt/) + assert.equal(erster.deleted.features, 0, 'die Funktionen werden nicht abgeschaltet, solange ihre Daten noch da sind') + assert.match(formatReport(plan, erster), /Fehlgeschlagen[\s\S]*oeffnungszeiten .*kaputt/) + for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'active') + assert.equal(zeitenDocs(kunde.customerId).length, 1, 'ein Dokument ist schon weg, eines geblieben') + + const zweiter = await applyDeletions(plan) + assert.deepEqual(zweiter.failed, []) + assert.equal(zweiter.deleted.zeiten, 1) + assert.deepEqual(zeitenDocs(kunde.customerId), []) + assert.deepEqual(merkerVon(kunde.features), []) + for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'disabled') +}) + +test('Löschfristen: Kunde ohne Zeiten-Funktion bleibt wie bisher (kein zeitenIds, keine Zeile im Bericht)', async () => { + nr += 1 + const customerId = `zl${nr}` + abo(`sub_${customerId}`, customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + aw.seed('portalFeatures', { $id: `f_${customerId}_news`, customerId, projectId: `p_${customerId}`, featureKey: 'news', state: 'active', enabled: true }) + + const plan = await findDueDeletions(JAN_2026) + const eintrag = plan.news.find((n) => n.customerId === customerId) + assert.deepEqual(eintrag.zeitenIds, []) + assert.doesNotMatch(formatReport(plan), new RegExp(`Kunde ${customerId}.*Öffnungszeiten`)) + const result = await applyDeletions(plan) + assert.deepEqual(result.failed, []) + assert.equal(result.deleted.zeiten, 0) + assert.equal(result.deleted.features, 1) + assert.doesNotMatch(formatReport(plan, result), /Öffnungszeiten:/) +})