Analyse: K18 mit PDFs und Nachweis, K19 neu, Tickets für Einschalten, Ausschalten und Veröffentlichen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:36 +00:00
1 parent 3bac4093af
commit fbc40252a3
5 files changed
+306 -28

No files matched your search

+102 -19
View File
@@ -10,6 +10,9 @@ 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}$/
@@ -20,7 +23,7 @@ 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 }
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments }
function httpError(status, message) {
const err = new Error(message)
@@ -123,17 +126,53 @@ export function validateTextform(textform, { today = berlinToday(new Date()), mi
return { date, channel }
}
/** Neubau anstossen; { ok, reason } mit reason kein_repo | preview_aus | fehler. Wirft nie. */
async function rebuildSafe(project) {
if (!project.repoFullName || project.previewMode === 'off') return
if (!project.repoFullName) return { ok: false, reason: 'kein_repo' }
if (project.previewMode === 'off') return { ok: false, reason: 'preview_aus' }
try {
await analyseDeps.rebuild(project)
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' }
}
}
async function mailSafe(customerId, template, project) {
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
@@ -141,9 +180,12 @@ async function mailSafe(customerId, template, project) {
template,
values: {
kunde_name: customer.name || '',
projekt: project.projectName || project.subdomain || '',
projekt: projectName(project),
portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`,
...values,
},
blocks,
attachments,
})
return true
} catch (err) {
@@ -152,22 +194,41 @@ async function mailSafe(customerId, template, project) {
}
}
function privacyTicket(customerId, project, siteKey) {
const name = project.projectName || project.subdomain || ''
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: [
`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'),
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) }),
})
}
@@ -268,13 +329,27 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
// 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)
const { ticketId } = await privacyTicket(customerId, project, doc.siteKey, rebuild)
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() })
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 }
})
@@ -313,7 +388,15 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
await rebuildSafe(project)
return { doc, changed: true }
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 }
})
}
+70
View File
@@ -0,0 +1,70 @@
// Geprüfte Texte der Website-Analyse für WOMS-Tickets (Quelle: banner-portal-mailtexte.md, Fassung 2026-10-02)
// § 6 (Tickets) und § 7 (Textbaustein für die Datenschutzerklärung des Kunden), 1:1 übernommen.
// "veröffentlichen"-Ticket und Grundtexte sind NEU und bewusst kurz.
export const PRIVACY_SNIPPET_DU = [
'Reichweitenmessung',
'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies, wie unsere Website genutzt wird. Solange Du im Banner nichts ausgewählt hast, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Weitere Daten, etwa von welcher Website Du kommst, Verweildauer und Klicks, erfassen wir nur mit Deiner Einwilligung. Die Messung betreibt für uns die WEBklar GbR als Auftragsverarbeiter auf einem Server in Deutschland. Einzelheiten, Rechtsgrundlagen, Speicherdauer und Deine Rechte, auch Dein Widerspruchsrecht, findest Du unter [Datenschutz bei der Reichweitenmessung](/datenschutz-analyse.html).',
].join('\n')
export const PRIVACY_SNIPPET_SIE = [
'Reichweitenmessung',
'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies, wie unsere Website genutzt wird. Solange Sie im Banner nichts ausgewählt haben, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Weitere Daten, etwa von welcher Website Sie kommen, Verweildauer und Klicks, erfassen wir nur mit Ihrer Einwilligung. Die Messung betreibt für uns die WEBklar GbR als Auftragsverarbeiter auf einem Server in Deutschland. Einzelheiten, Rechtsgrundlagen, Speicherdauer und Ihre Rechte, auch Ihr Widerspruchsrecht, finden Sie unter [Datenschutz bei der Reichweitenmessung](/datenschutz-analyse.html).',
].join('\n')
const FEATURE = 'Funktion: analyse'
const headLines = ({ name, domains }) => [FEATURE, `Projekt: ${name}`, `Domain(s): ${domains.join(', ')}`]
const lastDomain = (domains) => domains[domains.length - 1]
/** Ticket beim Einschalten: Textbaustein in die Datenschutzerklärung (Quelle § 6 „Beim Einschalten“). */
export function privacyTicketDetails({ name, domains, rebuildNote = '' }) {
return [
...headLines({ name, domains }),
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
...(rebuildNote ? [`Veröffentlichung: ${rebuildNote}`] : []),
'',
'1. Textbaustein (siehe unten) in die Datenschutzerklärung der Website einfügen, Link auf /datenschutz-analyse.html.',
'2. Fällt dabei ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), den Kunden im Chat darauf hinweisen. Die Datenschutzerklärung wird nicht inhaltlich geprüft und ohne Auftrag nicht weiter geändert.',
'3. Liegt die Datenschutzerklärung nicht in unserem Repo (externer Generator): Textbaustein per Chat an den Kunden senden.',
'4. Nicht auf das Stundenkontingent anrechnen (Leistungsbeschreibung Website-Analyse Nr. 4 Abs. 4).',
'',
'Textbaustein (Du-Fassung):',
PRIVACY_SNIPPET_DU,
'',
'Textbaustein (Sie-Fassung, für Datenschutzerklärungen, die siezen):',
PRIVACY_SNIPPET_SIE,
].join('\n')
}
/** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */
export function removalTicketDetails({ name, domains }) {
return [
...headLines({ name, domains }),
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
'',
'1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.',
'2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.',
'3. Nicht auf das Stundenkontingent anrechnen.',
].join('\n')
}
const PUBLISH_REASONS = {
kein_repo: 'Dem Projekt ist kein Repo zugeordnet, die Website wurde nicht automatisch neu veröffentlicht.',
preview_aus: 'Die Vorschau/Veröffentlichung ist für das Projekt ausgeschaltet, die Website wurde nicht automatisch neu veröffentlicht.',
fehler: 'Der automatische Neubau der Website ist fehlgeschlagen.',
}
export const publishReasonText = (reason) => PUBLISH_REASONS[reason] || PUBLISH_REASONS.fehler
/** NEU: Ticket „Website-Analyse veröffentlichen“, wenn der automatische Neubau beim Einschalten nicht lief. */
export function publishTicketDetails({ name, domains, reason }) {
return [
...headLines({ name, domains }),
`Grund: ${publishReasonText(reason)}`,
'',
'Website neu veröffentlichen, damit das Analyse-Skript (Banner) und die Zusatzseite /datenschutz-analyse.html erscheinen.',
'Nicht auf das Stundenkontingent anrechnen.',
].join('\n')
}