Files
Webklar-Kundenbereich/server/services/analyse.js
T
ClaudeandClaude Sonnet 5.5 144b053851 Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn
aboStatus.js (Kernversion aus B5 ausgebaut): ein privater Lader lage(customerId, { altbestand })
ueber getCurrentSubscription und listContractEvents, alle Praedikate bauen darauf. laeuft kennt
altbestand (Vorgabe true: productKey || 'wartung' plus Wechsel-Regel; false: die heutige Regel der
Analyse, keine Ereignisabfrage). Wechsel-Regel: sagt der Spiegel "Wartung laeuft" und ist das
juengste wechsel-Ereignis des Abos widerrufen, laeuft das Wartungs-Abo nicht (kein_abo beim
Bestell-Gate, Freischaltung bleibt in setup). Neu aboLage ({ laeuft, art, endedAt }) und
aboMemo().lage; der Memo merkt eine Ablehnung fuer den Lauf. analyse.js nutzt
laeuft(id, { altbestand: false }); Verhalten der Analyse unveraendert (+1 Pin-Test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:50:51 +00:00

588 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'])
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: `<subdomain>.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 <E-Mail>" (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. Wirft 400, sonst { date, channel }.
*/
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) {
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, '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
}