// 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 { 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' const FEATURE_KEY = 'analyse' const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ const COLOR_RE = /^#[0-9a-f]{6}$/ const DEFAULT_COLOR = '#111111' /** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */ export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket } 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 } } async function rebuildSafe(project) { if (!project.repoFullName || project.previewMode === 'off') return try { await analyseDeps.rebuild(project) } catch (err) { // Zustand bleibt gespeichert, der naechste Neubau holt nach console.warn(`[analyse] Neubau für Projekt ${project.$id} fehlgeschlagen:`, err.message) } } async function mailSafe(customerId, template, project) { try { const customer = await customerOf(customerId) if (!customer?.email) return false await sendCustomerMail(customer.email, { template, values: { kunde_name: customer.name || '', projekt: project.projectName || project.subdomain || '', portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`, }, }) return true } catch (err) { console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message) return false } } function privacyTicket(customerId, project, siteKey) { const name = project.projectName || project.subdomain || '' const domains = analyseHosts(project) return analyseDeps.ticket({ customerId, project, featureKey: FEATURE_KEY, siteKey, topic: `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`, details: [ `Funktion: ${FEATURE_KEY}`, `Projekt: ${name}`, `Domain(s): ${domains.join(', ')}`, `Zusatzseite: https://${domains[domains.length - 1]}/datenschutz-analyse.html`, 'Satz + Link in die bestehende Datenschutzerklärung einfügen', ].join('\n'), }) } export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) { if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) { throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen') } 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') { 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 now = new Date().toISOString() doc = await updateDocument(config.collections.portalFeatures, existing.$id, { ...stateFields('active'), siteKey, accentColor: color, avvVersion: consent.avvVersion, avvAcceptedAt: now, avvAcceptedBy: String(by || 'customer').slice(0, 100), activatedAt: now, offSince: '', activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18 }) 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 if (needTicket) { const { ticketId } = await privacyTicket(customerId, project, doc.siteKey) doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId }) } if (changed || needTicket || needMail) await rebuildSafe(project) if (needMail && (await mailSafe(customerId, 'K18', project))) { 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' }) { 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(), }) await rebuildSafe(project) return { doc, 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 }) 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 } }