611 lines
29 KiB
JavaScript
611 lines
29 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 { 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 { 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, nur durch den Kunden): ein Hosting- oder Wartungs-Abo genuegt, die erwartete Fassung
|
||
* ist die der Abo-Erklaerung (consentAboAnalyse().avvVersion). 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.
|
||
*/
|
||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date(), aboModell = false, nachweis = null }) {
|
||
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,
|
||
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 || '' })
|
||
.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
|
||
}
|