Analyse: Freischalten selbstheilend, Sperre, Retention-Abschaltung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 10:13:28 +00:00
1 parent f0eee196ed
commit 37203f00c4
6 files changed
+149 -33

No files matched your search

+1
View File
@@ -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 },
]
@@ -33,7 +33,7 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Website-Analyse</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse wurde beendet</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>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.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>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.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
+44 -30
View File
@@ -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 }
})
}
+13 -1
View File
@@ -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
+69 -1
View File
@@ -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')
})
+21
View File
@@ -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
}
})