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:
1 parent
fbc40252a3
commit
78170567b4
17 files changed
+616
-70
No files matched your search
+210
-50
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user