Files
Webklar-Kundenbereich/server/services/analyse.js
T
ClaudeandClaude Sonnet 5.5 4473a746ee Kundenansicht P6b: Team-Weg der Analyse im Abo-Modell (WOMS)
POST/GET /api/admin/analyse/:projectId kennt jetzt das Abo-Modell:
- Gilt aboModellFuerKunde, prüft das Einschalten das Abo über aboDeckt
  (Hosting und Wartung decken die Analyse); fehlt es, 409 mit code
  abo_fehlt, grund, wechsel (und ab) wie bei der Team-Aktivierung der
  anderen Funktionen, bevor etwas geschrieben wird. Lesefehler: 503.
- Das neu eingeschaltete Dokument trägt den Abo-Marker (unlockedBy und
  einrichtungAbrechnung 'abo', istAboModell); activateAnalyse nimmt dazu
  den Parameter abrechnung, setzt ihn im selben Schritt wie den Zustand
  und stellt ihn bei der Rücknahme wieder her.
- GET: Eignung und Text folgen dem Abo-Modell (Hosting-oder-Wartungs-Text).
- Alle anderen Kunden und das Ausschalten laufen unverändert.

Tests: test/adminAnalyseAboModell.test.js (9 Fälle).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:06:33 +00:00

621 lines
30 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 { aboDeckt, aboLaeuftFuer, aboModellFuerKunde } from './aboFunktionen.js'
import { ABRECHNUNGEN } from './aboModell.js'
import { consentAboAnalyse } from './featureRegistry.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 }
/** Im Abo-Modell (neue Ansicht) reicht auch ein Hosting-Abo: nur dieser Text weicht ab. */
export const ELIGIBILITY_MESSAGES_ABO = { kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.' }
export const eligibilityMessage = (reason, { aboModell = false } = {}) =>
(aboModell && ELIGIBILITY_MESSAGES_ABO[reason]) || ELIGIBILITY_MESSAGES[reason]
/** 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
}
}
/**
* @param {{ aboModell?: boolean }} [optionen] aboModell (neue Ansicht): ein laufendes Hosting- oder Wartungs-Abo genuegt (aboDeckt:
* nicht gekuendigt, aktiv; in der Probezeit gilt weiter kein_abo); sonst die strenge Regel "Wartungs-Abo laeuft".
* @returns {Promise<{ ok: boolean, reason?: string, project?: object }>}
*/
export async function analyseEligibility(customerId, projectId, { aboModell = false } = {}) {
const project = await loadOwnProject(customerId, projectId)
if (!project) return { ok: false, reason: 'nicht_dein_projekt' }
const aboOk = aboModell ? (await aboDeckt(customerId, FEATURE_KEY)).ok === true : await hasWartungsAbo(customerId)
if (!aboOk) 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 (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
}
/**
* Analyse einschalten. aboModell (neue Ansicht, Kunde oder Team): ein Hosting- oder Wartungs-Abo genuegt, die Fassung ist die der
* Abo-Erklaerung (consentAboAnalyse().avvVersion; beim Kunden auch die erwartete). nachweis: async () => void, laeuft im selben try wie
* recordConsent, nach dem Dokument und davor; ein Fehler nimmt alles zurueck (kein "on" ohne Nachweis). Beides nur bei einer neuen Aktivierung.
* abrechnung (Team im Abo-Modell, Nachzug P6b): 'abo' | 'projekt' setzt den Marker des Abo-Modells (unlockedBy und einrichtungAbrechnung,
* istAboModell) am Dokument, ebenfalls nur bei einer neuen Aktivierung und im selben Schritt (die Rücknahme stellt ihn wieder her); null
* (Vorgabe) schreibt keinen Marker.
*/
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date(), aboModell = false, nachweis = null, abrechnung = null }) {
if (abrechnung !== null && !ABRECHNUNGEN.includes(abrechnung)) throw httpError(400, 'Unbekannte Abrechnung')
const viaCustomer = by === 'customer'
const avvVersion = aboModell ? consentAboAnalyse().avvVersion : config.analyse.avvVersion
let accepted
let proof = { date: '', channel: '' }
if (viaCustomer) {
if (!consent || consent.accepted !== true || consent.avvVersion !== 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, { aboModell })
if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], eligibilityMessage(eligibility.reason, { aboModell }))
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,
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: 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,
...(abrechnung ? { unlockedBy: abrechnung, einrichtungAbrechnung: abrechnung } : {}),
activatedAt: at,
offSince: '',
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
privacyTicketId: '', // und ihre Tickets
removalTicketId: '',
publishTicketId: '',
})
updated = true
// Nachweis der Abo-Erklaerung (featureConsents) und Historie erst nach dem Dokument: scheitert einer, wird alles
// zurueckgenommen (kein "on" ohne Nachweis und ohne aktives Dokument)
await nachweis?.()
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 || '',
...(abrechnung ? { unlockedBy: existing.unlockedBy ?? null, einrichtungAbrechnung: existing.einrichtungAbrechnung ?? null } : {}),
})
.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
// Neue Ansicht (Abo-Modell): auch ein laufendes Hosting-Abo haelt die Analyse (gekuendigt zaehlt bis zum Abo-Ende); alle anderen wie bisher
if ((await aboModellFuerKunde(doc.customerId)) && (await aboLaeuftFuer(doc.customerId, FEATURE_KEY))) 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
}