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

+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
}