From 37203f00c47d40586145cc37311ad9bc1d749b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:13:28 +0000 Subject: [PATCH] Analyse: Freischalten selbstheilend, Sperre, Retention-Abschaltung Co-Authored-By: Claude Sonnet 5.5 --- scripts/analyse-setup.mjs | 1 + .../mail-templates/K19 Analyse beendet.html | 2 +- server/services/analyse.js | 74 +++++++++++-------- server/services/retention.js | 14 +++- test/analyse.test.js | 70 +++++++++++++++++- test/retention.test.js | 21 ++++++ 6 files changed, 149 insertions(+), 33 deletions(-) diff --git a/scripts/analyse-setup.mjs b/scripts/analyse-setup.mjs index d949b99..5a75b86 100644 --- a/scripts/analyse-setup.mjs +++ b/scripts/analyse-setup.mjs @@ -16,6 +16,7 @@ export const ANALYSE_ATTRIBUTES = [ { key: 'avvAcceptedBy', type: 'string', size: 100, required: false }, { key: 'activatedAt', type: 'string', size: 40, required: false }, { key: 'offSince', type: 'string', size: 40, required: false }, + { key: 'activatedMailAt', type: 'string', size: 40, required: false }, { key: 'privacyTicketId', type: 'string', size: 40, required: false }, ] diff --git a/server/mail-templates/K19 Analyse beendet.html b/server/mail-templates/K19 Analyse beendet.html index 98c4792..ad9b285 100644 --- a/server/mail-templates/K19 Analyse beendet.html +++ b/server/mail-templates/K19 Analyse beendet.html @@ -33,7 +33,7 @@ a{color:#111111;} - + diff --git a/server/services/analyse.js b/server/services/analyse.js index 5e2f5e0..c2422fa 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -14,8 +14,8 @@ const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ const COLOR_RE = /^#[0-9a-f]{6}$/ const DEFAULT_COLOR = '#111111' -/** Austauschbar fuer Tests: Sammeldienst-Client und Neubau. */ -export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild } +/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */ +export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket } function httpError(status, message) { const err = new Error(message) @@ -91,24 +91,26 @@ async function rebuildSafe(project) { async function mailSafe(customerId, template, project) { try { const customer = await customerOf(customerId) - if (!customer?.email) return + if (!customer?.email) return false await sendCustomerMail(customer.email, { template, values: { kunde_name: customer.name || '', projekt: project.projectName || project.subdomain || '', - portal_url: `${config.publicBaseUrl}/dashboard.html#news`, + portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`, }, }) + return true } catch (err) { console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message) + return false } } function privacyTicket(customerId, project, siteKey) { const name = project.projectName || project.subdomain || '' const domains = analyseDomains(project) - return createFeatureTicket({ + return analyseDeps.ticket({ customerId, project, featureKey: FEATURE_KEY, @@ -137,35 +139,47 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons const { project } = eligibility const existing = await ensureFeatureDoc(customerId, projectId, FEATURE_KEY) - if (existing.state === 'active') { - // Schon aktiv: nur die Farbe (Banner) aktualisieren, kein zweites Ticket, keine zweite Mail - if (existing.accentColor === color) return { doc: existing, changed: false } - const doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color }) - await rebuildSafe(project) - return { doc, changed: true } + if (existing.state === 'disabled' && by === 'customer') { + throw httpError(403, 'Analyse wurde gesperrt – bitte melde dich bei uns') } - const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey() - // Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar) - await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true }) - const now = new Date().toISOString() - let doc = await updateDocument(config.collections.portalFeatures, existing.$id, { - ...stateFields('active'), - siteKey, - accentColor: color, - avvVersion: consent.avvVersion, - avvAcceptedAt: now, - avvAcceptedBy: String(by || 'customer').slice(0, 100), - activatedAt: now, - offSince: '', - }) - if (!doc.privacyTicketId) { - const { ticketId } = await privacyTicket(customerId, project, siteKey) + let doc = existing + let changed = false + if (existing.state !== 'active') { + const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey() + // Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar) + await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true }) + const now = new Date().toISOString() + doc = await updateDocument(config.collections.portalFeatures, existing.$id, { + ...stateFields('active'), + siteKey, + accentColor: color, + avvVersion: consent.avvVersion, + avvAcceptedAt: now, + avvAcceptedBy: String(by || 'customer').slice(0, 100), + activatedAt: now, + offSince: '', + activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18 + }) + changed = true + } else if (existing.accentColor !== color) { + doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color }) + changed = true + } + + // Selbstheilend: was von frueheren Versuchen noch fehlt (Ticket, Mail), wird jetzt nachgeholt. + // Ticket vor Mail, Marker erst nach Erfolg => je genau ein Ticket und eine K18. + const needTicket = !doc.privacyTicketId + const needMail = !doc.activatedMailAt + if (needTicket) { + const { ticketId } = await privacyTicket(customerId, project, doc.siteKey) doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId }) } - await rebuildSafe(project) - await mailSafe(customerId, 'K18', project) - return { doc, changed: true } + if (changed || needTicket || needMail) await rebuildSafe(project) + if (needMail && (await mailSafe(customerId, 'K18', project))) { + doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() }) + } + return { doc, changed: changed || needTicket || needMail } }) } diff --git a/server/services/retention.js b/server/services/retention.js index 559f3b4..060a430 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -1,9 +1,10 @@ import { rm } from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' -import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js' +import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { disable } from './siteFeatures.js' +import { analyseDeps, analyseDomains } from './analyse.js' import { getStripe } from './stripeBilling.js' import { filesOf, STATUS } from './takeoverCredits.js' @@ -219,7 +220,18 @@ export async function applyDeletions(plan) { } for (const id of entry.featureIds) { const off = await attempt('portalFeatures', id, async () => { + const feature = await getDocument(FEATURES(), id).catch(() => null) await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) + // Analyse: auch den Sammeldienst abschalten (best effort, die Daten loescht dessen 60-Tage-Job) + if (feature?.featureKey === 'analyse' && feature.siteKey) { + try { + // der Sammeldienst verlangt mindestens eine Domain, auch beim Abschalten + const project = await getDocument(config.collections.websiteProjects, feature.projectId) + await analyseDeps.client.putSite(feature.siteKey, { projectId: feature.projectId, domains: analyseDomains(project), active: false }) + } catch (err) { + console.warn(`[retention] Sammeldienst für Analyse ${id} nicht abgeschaltet:`, err.message) + } + } return true }) if (off) deleted.features += 1 diff --git a/test/analyse.test.js b/test/analyse.test.js index b0cd60e..3b59779 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -7,7 +7,7 @@ import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' // Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest -const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId'] +const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt'] const aw = await startAppwriteMock({ schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], @@ -175,3 +175,71 @@ test('News-Jobs ignorieren Analyse-Dokumente', async () => { const active = await doc('c1', 'p1') assert.equal(await sf.findActiveBySiteKey(active.siteKey.slice(0, 16)), null, 'Analyse-siteKey ist kein News-Schlüssel') }) + +test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau und Mail genau einmal nach', async () => { + aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Heilung', subdomain: 'heilung', isPublic: true, repoFullName: 'WEBklar/heilung' }) + const original = an.analyseDeps.ticket + let failOnce = true + an.analyseDeps.ticket = async (...args) => { + if (failOnce) { failOnce = false; throw new Error('Ticketsystem down') } + return original(...args) + } + const before = analyseTickets().length + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }), /Ticketsystem down/) + assert.equal((await doc('c1', 'p6')).state, 'active') + assert.equal(rebuilds.length, 0) + assert.equal(mails.sent.length, 0) + const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) + assert.equal(r.doc.state, 'active') + assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt) + assert.equal(analyseTickets().length, before + 1) + assert.deepEqual(rebuilds, ['p6']) + assert.equal(mails.sent.length, 1) + // dritter Aufruf: nichts mehr offen + await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) + assert.equal(analyseTickets().length, before + 1) + assert.deepEqual(rebuilds, ['p6']) + assert.equal(mails.sent.length, 1) + an.analyseDeps.ticket = original +}) + +test('putSite scheitert: Dokument bleibt unverändert', async () => { + aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true }) + const ok = an.analyseDeps.client + an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } } + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', consent: consent() }), { status: 502 }) + assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true) + assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0) + an.analyseDeps.client = ok +}) + +test('Neubau scheitert: Analyse bleibt aktiv, kein Fehler', async () => { + aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' }) + const ok = an.analyseDeps.rebuild + an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') } + const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', consent: consent() }) + assert.equal(r.doc.state, 'active') + assert.equal(mails.sent.length, 1) + an.analyseDeps.rebuild = ok +}) + +test('parallel einschalten: ein Ticket, eine Mail', async () => { + aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Parallel', subdomain: 'parallel', isPublic: true }) + const before = analyseTickets().length + mails.sent.length = 0 + await Promise.all([ + an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), + an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), + ]) + assert.equal(analyseTickets().length, before + 1) + assert.equal(mails.sent.length, 1) +}) + +test('gesperrte Analyse: Kunde 403, Mitarbeiter darf', async () => { + aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true }) + const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse') + await sf.disable(g.$id, {}) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' }) + const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso' }) + assert.equal(r.doc.state, 'active') +}) diff --git a/test/retention.test.js b/test/retention.test.js index 4683a1d..5263630 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -306,3 +306,24 @@ test('retention_loescht_news: Beiträge, Fotos und Funktionen fälliger Kunden', assert.equal(feat.enabled, false) assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn2_feat').state, 'active') }) + +test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () => { + const { analyseDeps } = await import('../server/services/analyse.js') + sub('ra_sub', 'ra', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + stripeSub('ra_sub', 'ra') + aw.seed('websiteProjects', { $id: 'rap', customerId: 'ra', projectName: 'Alt', subdomain: 'altprojekt', isPublic: true }) + aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' }) + const puts = [] + const ok = analyseDeps.client + analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } } + try { + const plan = await findDueDeletions(JAN_2026) + assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat')) + const result = await applyDeletions(plan) + assert.deepEqual(result.failed, []) + assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]]) + assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled') + } finally { + analyseDeps.client = ok + } +})
Website-Analyse
Deine Website-Analyse wurde beendet
Hallo {{kunde_name}},
die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde oder Du die Analyse ausgeschaltet hast. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht.
Hallo {{kunde_name}},
die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht.
FunktionWebsite-Analyse
Projekt{{projekt}}
Zum Kundenportal →
Viele Grüße
Dein WEBklar-Team