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

232 lines
9.9 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 { 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]$/
/** `<subdomain>.project.webklar.com` plus Live-Domain (klein, ohne Schema/Pfad), ohne Doppelte; ungueltige Hosts werden uebersprungen. */
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)].filter((d) => HOSTNAME_RE.test(d))
}
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 = analyseDomains(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 }
}