Files
Webklar-Kundenbereich/server/services/analyse.js
T
2026-10-01 10:10:39 +00:00

213 lines
8.7 KiB
JavaScript

// 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 und Neubau. */
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild }
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 }
}
/** `<subdomain>.project.webklar.com` plus Live-Domain (klein, ohne Schema/Pfad), ohne Doppelte. */
export function analyseDomains(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)]
}
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
await sendCustomerMail(customer.email, {
template,
values: {
kunde_name: customer.name || '',
projekt: project.projectName || project.subdomain || '',
portal_url: `${config.publicBaseUrl}/dashboard.html#news`,
},
})
} catch (err) {
console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message)
}
}
function privacyTicket(customerId, project, siteKey) {
const name = project.projectName || project.subdomain || ''
const domains = analyseDomains(project)
return createFeatureTicket({
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
const existing = await ensureFeatureDoc(customerId, projectId, FEATURE_KEY)
if (existing.state === 'active') {
// Schon aktiv: nur die Farbe (Banner) aktualisieren, kein zweites Ticket, keine zweite Mail
if (existing.accentColor === color) return { doc: existing, changed: false }
const doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color })
await rebuildSafe(project)
return { doc, changed: true }
}
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()
let 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: '',
})
if (!doc.privacyTicketId) {
const { ticketId } = await privacyTicket(customerId, project, siteKey)
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId })
}
await rebuildSafe(project)
await mailSafe(customerId, 'K18', project)
return { doc, changed: true }
})
}
export async function deactivateAnalyse({ customerId, projectId, now = new Date() }) {
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 }
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 }
}