// Website-Analyse: ein-/ausschalten je Projekt, Wartungs-Abo-Pruefung, Datenschutz-Ticket, Kundenmails import { config } from '../config.js' import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { analyticsClient } from './analyticsClient.js' import { analysePrivacyData } from './analysePrivacy.js' import { recordConsent } from './analyseConsent.js' import { withLocks } from './keyedLock.js' import { sendCustomerMail } from './mailer.js' import { triggerRebuild } from './previewRebuild.js' import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js' import { laeuft } from './aboStatus.js' import { createFeatureTicket } from './ticketAdmin.js' import { berlinDateTime } from './berlinTime.js' import { legalPaths, legalPdfAttachments } from './analyseLegal.js' import { privacyTicketDetails, publishTicketDetails, removalTicketDetails, publishReasonText } from './analyseTexts.js' const FEATURE_KEY = 'analyse' const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ const COLOR_RE = /^#[0-9a-f]{6}$/ const DEFAULT_COLOR = '#111111' const CHANNELS = new Set(['chat', 'email']) /** Wege der Zustimmung bei Funktionen (Terminplaner, Webshop, Buchungskalender): zusaetzlich WhatsApp (eine Nachricht dort ist Textform). */ export const FUNKTION_CHANNELS = new Set(['chat', 'email', 'whatsapp']) 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, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) } function httpError(status, message) { const err = new Error(message) err.status = status return err } export const ELIGIBILITY_MESSAGES = { nicht_dein_projekt: 'Kein Zugriff auf dieses Projekt', kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.', nicht_oeffentlich: 'Die Website-Analyse gibt es nur für öffentliche Websites.', keine_subdomain: 'Für dieses Projekt ist noch keine Subdomain eingerichtet.', } const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 } /** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */ const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false }) async function loadOwnProject(customerId, projectId) { try { return await assertOwnProject(customerId, projectId) } catch (err) { if (err.status === 403) return null throw err } } /** @returns {Promise<{ ok: boolean, reason?: string, project?: object }>} */ export async function analyseEligibility(customerId, projectId) { const project = await loadOwnProject(customerId, projectId) if (!project) return { ok: false, reason: 'nicht_dein_projekt' } if (!(await hasWartungsAbo(customerId))) return { ok: false, reason: 'kein_abo' } if (project.isPublic !== true) return { ok: false, reason: 'nicht_oeffentlich' } if (!project.subdomain) return { ok: false, reason: 'keine_subdomain' } return { ok: true, project } } const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/ /** Gespeicherte Hosts: `.project.webklar.com` plus Live-Domain (klein, ohne Schema/Pfad), ohne Doppelte; ungueltige werden uebersprungen. */ export function analyseHosts(project) { const domains = [] if (project.subdomain) domains.push(`${String(project.subdomain).trim().toLowerCase()}.project.webklar.com`) const live = String(project.liveDomain || '') .trim() .toLowerCase() .replace(/^[a-z]+:\/\//, '') .replace(/[/?#].*$/, '') if (live) domains.push(live) return [...new Set(domains)].filter((d) => HOSTNAME_RE.test(d)) } /** Erlaubte Origin-Domains des Messdienstes: gespeicherte Hosts plus www-/Apex-Gegenstueck der Live-Domain (Besucher kommen mit und ohne www), max. 5. */ export function analyseDomains(project) { const hosts = analyseHosts(project) const live = project.liveDomain ? hosts[hosts.length - 1] : '' const extra = live && hosts.length > 1 ? (live.startsWith('www.') ? live.slice(4) : `www.${live}`) : '' return [...new Set([...hosts, extra].filter(Boolean))].filter((d) => HOSTNAME_RE.test(d)).slice(0, 5) } /** Dev-Server-Projekte (previewDetected 'dev…') bekommen kein Messskript: der Runner injiziert nur in veroeffentlichte Ordner. */ export const isDevPreview = (project) => String(project?.previewDetected || '').startsWith('dev') async function customerOf(customerId) { try { return await getDocument(config.collections.customers, customerId) } catch (err) { if (err.status === 404) return null throw err } } /** Verantwortlichen-Angaben des Kunden (Vorschau im Portal, Sperre beim Einschalten). */ export async function controllerView(customerId) { const { ok, company, address, email, missing } = analysePrivacyData(await customerOf(customerId)) return { company, address, email, ok, missing } } /** "Name " (oder was davon da ist), max. 100 Zeichen */ const personLabel = (p) => (p?.name && p?.email ? `${p.name} <${p.email}>` : p?.name || p?.email || '').slice(0, 100) const berlinToday = (now) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(now) /** * Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin, * nicht vor der Fassung) und Weg chat/email (channels: erlaubte Wege, Funktionen auch whatsapp). Wirft 400, sonst { date, channel }. */ export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion, channels = CHANNELS } = {}) { const date = String(textform?.date ?? '') const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date if (!real) throw httpError(400, 'Datum der Zustimmung fehlt oder ist ungültig') if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft') if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`) const channel = String(textform?.channel ?? '') if (!channels.has(channel)) throw httpError(400, channels.has('whatsapp') ? 'Weg der Zustimmung fehlt: Chat, E-Mail oder WhatsApp' : 'Weg der Zustimmung fehlt: Chat oder E-Mail') return { date, channel } } /** Neubau anstossen; { ok, reason } mit reason kein_repo | preview_aus | fehler. Wirft nie. */ async function rebuildSafe(project) { if (!project.repoFullName) return { ok: false, reason: 'kein_repo' } if (project.previewMode === 'off') return { ok: false, reason: 'preview_aus' } try { const result = await analyseDeps.rebuild(project) if (result?.error) throw new Error(String(result.error)) return { ok: true } } catch (err) { // Zustand bleibt gespeichert, der naechste Neubau holt nach console.warn(`[analyse] Neubau für Projekt ${project.$id} fehlgeschlagen:`, err.message) return { ok: false, reason: 'fehler' } } } const absolute = (path) => `${config.publicBaseUrl}${path}` const projectName = (project) => project.projectName || project.subdomain || '' const WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' } function zeitpunkt(iso) { const at = new Date(iso) if (Number.isNaN(at.getTime())) return '' const { date, time } = berlinDateTime(at) return `${date}, ${time.slice(0, 2)}:${time.slice(2)} Uhr` } const germanDate = (iso) => String(iso || '').split('-').reverse().join('.') /** K18-Werte und -Bloecke aus dem Portal-Dokument (Nachweis der Zustimmung). */ function k18Parts(doc) { const textform = Boolean(doc.avvConsentDate) const paths = legalPaths(doc.avvVersion || config.analyse.avvVersion) return { values: { fassung: doc.avvVersion || config.analyse.avvVersion, zeitpunkt: zeitpunkt(doc.avvAcceptedAt), person: doc.avvAcceptedBy || '', datum: germanDate(doc.avvConsentDate), weg: WEG[doc.avvConsentChannel] || '', avv_url: absolute(paths.avv), lb_url: absolute(paths.lb), }, blocks: { portal_weg: !textform, textform_weg: textform }, } } async function mailSafe(customerId, template, project, { values = {}, blocks = {}, attachments = [] } = {}) { try { const customer = await customerOf(customerId) if (!customer?.email) return false await sendCustomerMail(customer.email, { template, values: { kunde_name: customer.name || '', projekt: projectName(project), portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`, ...values, }, blocks, attachments, }) return true } catch (err) { console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message) return false } } function privacyTicket(customerId, project, siteKey, rebuild) { const name = projectName(project) const domains = analyseHosts(project) const rebuildNote = rebuild && !rebuild.ok ? `${publishReasonText(rebuild.reason)} Dafür gibt es ein eigenes Ticket „Website-Analyse von ${name} veröffentlichen“.` : '' return analyseDeps.ticket({ customerId, project, featureKey: FEATURE_KEY, siteKey, topic: `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`, details: privacyTicketDetails({ name, domains, rebuildNote }), }) } function publishTicket(customerId, project, siteKey, reason) { const name = projectName(project) return analyseDeps.ticket({ customerId, project, featureKey: FEATURE_KEY, siteKey, topic: `Website-Analyse von ${name} veröffentlichen`, details: publishTicketDetails({ name, domains: analyseHosts(project), reason }), }) } 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: 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 let proof = { date: '', channel: '' } if (viaCustomer) { if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) { throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen') } // Name und E-Mail der handelnden Person (bei Admin-Sitzung der Admin, siehe Route) accepted = personLabel(actor) if (!accepted) throw httpError(400, 'Bitte melde Dich neu an') } else { if (audienceConfirmed !== true) throw httpError(400, 'Bitte bestätigen, dass die Website sich nicht gezielt an Kinder unter 16 Jahren richtet') const t = validateTextform(textform, { today: berlinToday(now) }) proof = { date: t.date, channel: t.channel } accepted = String(by).slice(0, 100) } const color = accentColor === undefined || accentColor === null || accentColor === '' ? DEFAULT_COLOR : String(accentColor).toLowerCase() if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe') return withLocks([`analyse:${projectId}`], async () => { const eligibility = await analyseEligibility(customerId, projectId) if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], ELIGIBILITY_MESSAGES[eligibility.reason]) const { project } = eligibility if (!analyseDomains(project).length) throw httpError(409, 'Keine gültige Domain für die Analyse') const existing = await ensureFeatureDoc(customerId, projectId, FEATURE_KEY) if (existing.state === 'disabled' && by === 'customer') { throw httpError(403, 'Analyse wurde gesperrt – bitte melde dich bei uns') } let doc = existing let changed = false if (existing.state !== 'active') { // Ohne Verantwortlichen-Angaben bliebe die Zusatzseite leer: nicht einschalten (kein Skript, kein Ticket, keine Mail) if (!analysePrivacyData(await customerOf(customerId)).ok) throw httpError(409, MISSING_PRIVACY_MESSAGE) 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 at = now.toISOString() const entry = { customerId, projectId, siteKey, event: 'on', avvVersion: config.analyse.avvVersion, via: viaCustomer ? 'portal' : 'woms', acceptedAt: at, acceptedBy: accepted, textformDate: proof.date, textformChannel: proof.channel, audienceConfirmed: true, // Kunden-Haken enthaelt die Zielgruppen-Aussage; Mitarbeiter bestaetigen sie getrennt audienceTextVersion: config.analyse.avvVersion, } let updated = false try { doc = await updateDocument(config.collections.portalFeatures, existing.$id, { ...stateFields('active'), siteKey, accentColor: color, avvVersion: entry.avvVersion, avvAcceptedAt: at, avvAcceptedBy: accepted, avvConsentDate: proof.date, avvConsentChannel: proof.channel, audienceConfirmed: true, audienceTextVersion: entry.audienceTextVersion, activatedAt: at, offSince: '', activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18 privacyTicketId: '', // und ihre Tickets removalTicketId: '', publishTicketId: '', }) updated = true // Nachweis erst nach dem Dokument: scheitert er, wird alles zurueckgenommen (kein "on" ohne aktives Dokument) await analyseDeps.recordConsent(entry) } catch (err) { await analyseDeps.client .putSite(siteKey, { projectId, domains: analyseDomains(project), active: false }) .catch((e) => console.warn(`[analyse] Rücknahme für Projekt ${projectId} fehlgeschlagen:`, e.message)) if (updated) { await updateDocument(config.collections.portalFeatures, existing.$id, { ...stateFields(existing.state || 'off'), offSince: existing.offSince || '' }) .catch((e) => console.warn(`[analyse] Zurücksetzen für Projekt ${projectId} fehlgeschlagen:`, e.message)) } throw err } 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 // Erst Neubau, dann Tickets: Das Ergebnis steht im Datenschutz-Ticket und entscheidet ueber das Veroeffentlichen-Ticket const rebuild = changed || needTicket || needMail ? await rebuildSafe(project) : null if (needTicket) { const { ticketId } = await privacyTicket(customerId, project, doc.siteKey, rebuild) doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId }) } if (rebuild && !rebuild.ok && !doc.publishTicketId) { const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason) doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId }) } 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) if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt) const existing = await getFeatureDoc(customerId, projectId, FEATURE_KEY) 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 } 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, [ Query.equal('featureKey', FEATURE_KEY), Query.equal('state', 'active'), Query.limit(1000), ]) const deactivated = [] for (const doc of docs) { try { // Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse if (await hasWartungsAbo(doc.customerId)) continue const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' }) if (!changed) continue deactivated.push(doc.$id) const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({})) await mailSafe(doc.customerId, 'K19', project) } catch (err) { console.error(`[analyse] Abo-Prüfung für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) } } 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 || '')) { // Gleiche Sperre wie Ein-/Ausschalten, Dokument nachlesen: eine inzwischen neu aktivierte Analyse bleibt an await withLocks([`analyse:${doc.projectId}`], async () => { const fresh = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), Query.equal('projectId', doc.projectId), Query.limit(1)]) const cur = fresh[0] if (!cur || cur.state === 'active' || !SITE_KEY_RE.test(cur.siteKey || '')) return await analyseDeps.client.stopSite(cur.siteKey) stats.stopped.push(cur.$id) if (!cur.removalTicketId && offMarker(cur) >= config.analyse.avvVersion) { const project = await projectOf(cur.projectId) if (project && (await customerOf(cur.customerId))) { await ensureRemovalTicket(cur, project) stats.tickets.push(cur.$id) } } }) } } catch (err) { console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) } } return stats }