Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)

Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite.
analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged,
analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg,
Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen).
Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte).
Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:36 +00:00
1 parent fbc40252a3
commit 78170567b4
17 files changed
+616 -70

No files matched your search

+5 -1
View File
@@ -22,7 +22,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck } from './services/analyse.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -182,6 +182,10 @@ const server = app.listen(config.port, '0.0.0.0', () => {
runAboCheck({ now: new Date() })
.then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`))
.catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message))
// danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets
.then(() => runAnalyseReconcile({ now: new Date() }))
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
+9
View File
@@ -1048,6 +1048,15 @@ async function withdrawOrder(sub, d, receivedAt) {
}
}
}
if (inTime && type === 'wechsel') {
// Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran)
try {
const { endAnalyseForCustomer } = await import('../services/analyse.js')
await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt })
} catch (err) {
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
}
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
return {
sub,
+210 -50
View File
@@ -23,7 +23,7 @@ export const MISSING_PRIVACY_MESSAGE =
'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments }
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) }
function httpError(status, message) {
const err = new Error(message)
@@ -220,18 +220,65 @@ function publishTicket(customerId, project, siteKey, reason) {
})
}
function removalTicket(customerId, project, siteKey) {
const removalTopic = (project) => `Website-Analyse von ${projectName(project)} entfernen`
/** Zeitpunkt dieser Abschaltung: kennzeichnet das Entfernen-Ticket (ein Ticket je Abschaltung). */
const offMarker = (doc) => String(doc.offSince || doc.disabledAt || '')
function removalTicket(customerId, project, siteKey, offSince) {
const name = projectName(project)
return analyseDeps.ticket({
customerId,
project,
featureKey: FEATURE_KEY,
siteKey,
topic: `Website-Analyse von ${name} entfernen`,
details: removalTicketDetails({ name, domains: analyseHosts(project) }),
topic: removalTopic(project),
details: removalTicketDetails({ name, domains: analyseHosts(project), offSince }),
})
}
/** Schon angelegtes Entfernen-Ticket dieser Abschaltung (Speichern der ID war gescheitert) oder null. Suchfehler werfen: dann lieber kein zweites Ticket. */
async function findRemovalTicket(customerId, project, marker) {
if (!marker) return null
const tickets = await listDocuments(config.collections.workorders, [Query.equal('customerId', customerId), Query.limit(1000)])
return tickets.find((t) => t.topic === removalTopic(project) && String(t.details || '').includes(`Abgeschaltet: ${marker}`)) || null
}
/**
* Entfernen-Ticket fuer die aktuelle Abschaltung sicherstellen (idempotent): vorhandene ID gilt, sonst ein bereits
* angelegtes Ticket uebernehmen, sonst neu anlegen. Gibt das (ggf. aktualisierte) Dokument zurueck. Wirft bei Fehlern.
*/
async function ensureRemovalTicket(doc, project) {
if (doc.removalTicketId) return doc
const marker = offMarker(doc)
const found = await findRemovalTicket(doc.customerId, project, marker)
const ticketId = found ? found.$id : (await removalTicket(doc.customerId, project, doc.siteKey || '', marker)).ticketId
const saved = await analyseDeps.saveRemovalTicketId(doc.$id, ticketId)
return saved || { ...doc, removalTicketId: ticketId }
}
async function projectOf(projectId) {
try {
return await getDocument(config.collections.websiteProjects, projectId)
} catch (err) {
if (err.status === 404) return null
throw err
}
}
/** K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung). */
async function sendK18(doc, project) {
let attachments = null
try {
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
} catch (err) {
console.error(`[analyse] K18-Anhänge für Projekt ${doc.projectId} nicht lesbar:`, err.message)
}
if (attachments && (await mailSafe(doc.customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
return updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
}
return doc
}
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) {
const viaCustomer = by === 'customer'
let accepted
@@ -339,22 +386,55 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason)
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId })
}
if (needMail) {
// K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung)
let attachments = null
try {
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
} catch (err) {
console.error(`[analyse] K18-Anhänge für Projekt ${projectId} nicht lesbar:`, err.message)
}
if (attachments && (await mailSafe(customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
}
}
if (needMail) doc = await sendK18(doc, project)
return { doc, changed: changed || needTicket || needMail }
})
}
/**
* Gemeinsamer Kern aller Beendigungswege: Messdienst abschalten (stopSite), Dokument auf off + offSince,
* Historie, optional Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Fehler beim Messdienst werfen
* (Dokument bleibt dann unveraendert, der Abgleich holt nach); Historie, Neubau und Ticket nie.
*/
export async function endAnalyse({ doc, project, reason, now = new Date(), by = 'system', actor, tickets = true, rebuild = true }) {
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('off'),
offSince: now.toISOString(),
})
try {
await analyseDeps.recordConsent({
customerId: doc.customerId,
projectId: doc.projectId,
siteKey: doc.siteKey || '',
event: 'off',
avvVersion: config.analyse.avvVersion,
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
acceptedAt: now.toISOString(),
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
textformDate: '',
textformChannel: '',
})
} catch (err) {
// Ausschalten darf nie am Nachweis scheitern
console.warn(`[analyse] Historie (aus) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
console.log(`[analyse] Projekt ${doc.projectId} beendet (${reason || 'ausgeschaltet'})`)
let result = updated
if (project) {
if (rebuild) await rebuildSafe(project)
if (tickets && (await customerOf(doc.customerId).catch(() => null))) {
try {
result = await ensureRemovalTicket(updated, project)
} catch (err) {
// Ausschalten darf nie am Ticket scheitern; der taegliche Abgleich holt es nach
console.warn(`[analyse] Entfernen-Ticket für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
}
return { doc: result, changed: true }
}
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) {
return withLocks([`analyse:${projectId}`], async () => {
const project = await loadOwnProject(customerId, projectId)
@@ -363,43 +443,32 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
if (!existing || existing.state === 'off') return { doc: existing, changed: false }
// Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter)
if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false }
if (existing.siteKey) {
await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false })
}
const doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
...stateFields('off'),
offSince: now.toISOString(),
})
try {
await analyseDeps.recordConsent({
customerId,
projectId,
siteKey: existing.siteKey || '',
event: 'off',
avvVersion: config.analyse.avvVersion,
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
acceptedAt: now.toISOString(),
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
textformDate: '',
textformChannel: '',
})
} catch (err) {
// Ausschalten darf nie am Nachweis scheitern
console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
await rebuildSafe(project)
let result = doc
try {
const { ticketId } = await removalTicket(customerId, project, existing.siteKey || '')
result = await updateDocument(config.collections.portalFeatures, doc.$id, { removalTicketId: ticketId })
} catch (err) {
// Ausschalten darf nie am Ticket scheitern
console.warn(`[analyse] Entfernen-Ticket für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
return { doc: result, changed: true }
return endAnalyse({ doc: existing, project, reason: 'ausgeschaltet', now, by, actor })
})
}
const featureDocsOf = (extra = []) => listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), ...extra, Query.limit(1000)])
/**
* Alle aktiven Analysen eines Kunden beenden (Kunde geloescht, Widerruf Wechsel). Ein Fehler bei einem Dokument
* stoppt die uebrigen nicht; was bleibt, beendet der taegliche Abgleich. Bei mail:true bekommt der Kunde K19.
*/
export async function endAnalyseForCustomer(customerId, { reason, mail = false, now = new Date() } = {}) {
const gone = reason === 'kunde_geloescht'
const ended = []
for (const doc of await featureDocsOf([Query.equal('customerId', customerId), Query.equal('state', 'active')])) {
try {
const project = await projectOf(doc.projectId).catch(() => null)
await withLocks([`analyse:${doc.projectId}`], () => endAnalyse({ doc, project, reason, now, by: 'system', tickets: !gone, rebuild: !gone }))
ended.push(doc.$id)
if (mail) await mailSafe(customerId, 'K19', project || {})
} catch (err) {
console.error(`[analyse] Beenden (${reason}) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
return { ended }
}
/** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */
export async function runAboCheck({ now = new Date() } = {}) {
const docs = await listDocuments(config.collections.portalFeatures, [
@@ -423,3 +492,94 @@ export async function runAboCheck({ now = new Date() } = {}) {
}
return { checked: docs.length, deactivated }
}
const INELIGIBLE = (project) => (project.isPublic !== true ? 'nicht_oeffentlich' : !project.subdomain ? 'keine_subdomain' : null)
/** Nach einer Aenderung am Projekt (Subdomain, Oeffentlichkeit): Berechtigung weg => beenden, sonst Domains nachziehen. */
export async function analyseOnProjectChanged(projectId) {
const project = await projectOf(projectId)
const docs = await featureDocsOf([Query.equal('projectId', projectId), Query.equal('state', 'active')])
for (const doc of docs) {
await withLocks([`analyse:${projectId}`], async () => {
if (!project) return endAnalyse({ doc, project: null, reason: 'projekt_geloescht', tickets: false, rebuild: false })
const reason = INELIGIBLE(project)
if (reason) return endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich' })
const domains = analyseDomains(project)
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId, domains, active: true })
})
}
}
/**
* Sperre/Loeschfrist (siteFeatures.disable): Messdienst per stopSite abschalten (auch ohne Projekt), offSince setzen,
* Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Wirft nie.
*/
export async function analyseDisabled(doc) {
try {
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
} catch (err) {
console.warn(`[analyse] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
}
try {
let current = doc
if (!doc.offSince && doc.siteKey) {
current = await updateDocument(config.collections.portalFeatures, doc.$id, { offSince: doc.disabledAt || new Date().toISOString() })
}
const project = await projectOf(doc.projectId).catch(() => null)
if (!project || !current.siteKey || !(await customerOf(doc.customerId).catch(() => null))) return
await rebuildSafe(project)
await ensureRemovalTicket(current, project)
} catch (err) {
console.warn(`[analyse] Nacharbeit nach Sperre für ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
/**
* Taeglicher Abgleich (nach runAboCheck), je Analyse-Dokument unabhaengig:
* - active: Projekt oder Kunde weg => beenden (ohne Ticket/Neubau); nicht mehr berechtigt => beenden; sonst Domains
* nachziehen (putSite) und K18 wiederholen, falls sie fehlt;
* - off/disabled: Messdienst abschalten (idempotent) und fehlendes Entfernen-Ticket nachholen (nur Abschaltungen ab der
* Fassung, also nach Livegang).
*/
export async function runAnalyseReconcile({ now = new Date() } = {}) {
const docs = await featureDocsOf()
const stats = { checked: docs.length, ended: [], stopped: [], tickets: [] }
for (const doc of docs) {
try {
if (doc.state === 'active') {
await withLocks([`analyse:${doc.projectId}`], async () => {
const project = await projectOf(doc.projectId)
if (!project) {
await endAnalyse({ doc, project: null, reason: 'projekt_geloescht', now, tickets: false, rebuild: false })
return stats.ended.push(doc.$id)
}
if (!(await customerOf(doc.customerId))) {
await endAnalyse({ doc, project, reason: 'kunde_geloescht', now, tickets: false, rebuild: false })
return stats.ended.push(doc.$id)
}
if (INELIGIBLE(project)) {
await endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich', now })
return stats.ended.push(doc.$id)
}
const domains = analyseDomains(project)
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains, active: true })
if (!doc.activatedMailAt) await sendK18(doc, project)
})
} else if ((doc.state === 'off' || doc.state === 'disabled') && SITE_KEY_RE.test(doc.siteKey || '')) {
await analyseDeps.client.stopSite(doc.siteKey)
stats.stopped.push(doc.$id)
const marker = offMarker(doc)
if (!doc.removalTicketId && marker >= config.analyse.avvVersion) {
const project = await projectOf(doc.projectId)
if (project && (await customerOf(doc.customerId))) {
await ensureRemovalTicket(doc, project)
stats.tickets.push(doc.$id)
}
}
}
} catch (err) {
console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
return stats
}
+2 -1
View File
@@ -39,7 +39,7 @@ export function privacyTicketDetails({ name, domains, rebuildNote = '' }) {
}
/** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */
export function removalTicketDetails({ name, domains }) {
export function removalTicketDetails({ name, domains, offSince = '' }) {
return [
...headLines({ name, domains }),
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
@@ -47,6 +47,7 @@ export function removalTicketDetails({ name, domains }) {
'1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.',
'2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.',
'3. Nicht auf das Stundenkontingent anrechnen.',
...(offSince ? ['', `Abgeschaltet: ${offSince}`] : []), // Kennzeichen je Abschaltung (verhindert doppelte Tickets)
].join('\n')
}
+9
View File
@@ -47,6 +47,15 @@ export function createAnalyticsClient(opts = {}) {
putSite(siteKey, { projectId, domains, active }) {
return request('PUT', sitePath(siteKey), { projectId, domains, active })
},
/** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */
async stopSite(siteKey) {
try {
return await request('POST', `${sitePath(siteKey)}/off`)
} catch (err) {
if (err.status === 404) return { ok: true, missing: true }
throw err
}
},
deleteSite(siteKey) {
return request('DELETE', sitePath(siteKey))
},
+8
View File
@@ -274,6 +274,14 @@ export async function deleteCustomerWithPortalAccess(customerId) {
throw error
}
// Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach
try {
const { endAnalyseForCustomer } = await import('./analyse.js')
await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false })
} catch (err) {
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+8
View File
@@ -517,6 +517,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
}
// Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht
try {
const { analyseOnProjectChanged } = await import('./analyse.js')
await analyseOnProjectChanged(projectId)
} catch (err) {
console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message)
}
return {
project: updated,
deploy: deployResult,
+3 -4
View File
@@ -302,13 +302,12 @@ export async function disable(docId, { by, adminNote } = {}) {
return doc
}
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert.
// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert.
// Dynamischer Import: analyse.js importiert dieses Modul.
async function stopAnalyseCollecting(doc) {
try {
const { analyseDeps, analyseDomains } = await import('./analyse.js')
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false })
const { analyseDisabled } = await import('./analyse.js')
await analyseDisabled(doc)
} catch (err) {
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
}