// 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 { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.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 } 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. */ async function hasWartungsAbo(customerId) { const sub = await getCurrentSubscription(customerId) return Boolean(sub && sub.productKey === 'wartung' && ACTIVE_STATUSES.has(sub.status)) } 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. 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 }), }) } function removalTicket(customerId, project, siteKey) { 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) }), }) } 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) { // 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() }) } } return { doc, changed: changed || needTicket || needMail } }) } 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 } 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 } }) } /** 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 } }