Analyse: Freischalten selbstheilend, Sperre, Retention-Abschaltung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0eee196ed
commit
37203f00c4
6 files changed
+149
-33
No files matched your search
@@ -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 →</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
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user