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;}
|
| 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. |
| Funktion | Website-Analyse | | Projekt | {{projekt}} |
|
|
Viele Grüße Dein WEBklar-Team |
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
+ }
+})