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:
1 parent
3bac4093af
commit
fbc40252a3
5 files changed
+306
-28
No files matched your search
@@ -7,7 +7,8 @@
|
||||
Betreff: Deine Website-Analyse ist aktiv – {{projekt}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}, {{fassung}}, {{zeitpunkt}}, {{person}}, {{datum}}, {{weg}}, {{avv_url}}, {{lb_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): portal_weg, textform_weg
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -33,8 +34,15 @@ a{color:#111111;}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">✓ Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse ist aktiv</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist eingeschaltet. Besucher sehen jetzt einen Hinweis-Banner, Du findest die Auswertung im Kundenportal. Wir ergänzen Deine Datenschutzerklärung um einen Hinweis auf die Analyse und melden uns, sobald das erledigt ist.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist eingeschaltet. Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung Deiner Website. Die lösen wir jetzt aus; in der Regel ist es nach wenigen Minuten da. Die Auswertung findest Du im Kundenportal. Wir fügen in Deine Datenschutzerklärung einen Hinweis auf die Zusatzseite ein und melden uns, sobald das erledigt ist. Liegt Deine Datenschutzerklärung nicht bei uns, schicken wir Dir den Textbaustein im Chat.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Vereinbart</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag Website-Analyse</a> und <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung Website-Analyse</a>, Fassung {{fassung}} (beide als PDF im Anhang und verlinkt)</td></tr>
|
||||
<!-- WENN portal_weg -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Zugestimmt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeitpunkt}} durch {{person}}</td></tr>
|
||||
<!-- ENDE portal_weg -->
|
||||
<!-- WENN textform_weg -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Zugestimmt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">in Textform am {{datum}} per {{weg}}</td></tr>
|
||||
<!-- ENDE textform_weg -->
|
||||
</table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auswertung öffnen →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
|
||||
@@ -33,7 +33,7 @@ a{color:#111111;}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse wurde beendet</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht.</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website wurde beendet, weil Dein Wartungs-Abo beendet wurde. Ab jetzt werden keine Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website. Den Hinweis in Deiner Datenschutzerklärung entfernen wir ebenfalls, wenn sie bei uns liegt; sonst entferne ihn bitte selbst. Die bisher gesammelten Daten löschen wir 60 Tage nach dem Ende. Möchtest Du bis dahin eine Kopie, schreib uns bis zum 46. Tag im Chat.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
|
||||
+102
-19
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
+122
-5
@@ -46,6 +46,7 @@ const textform = { date: '2026-10-02', channel: 'email' }
|
||||
const NOW = new Date('2026-10-05T10:00:00.000Z')
|
||||
const consents = (p) => cl.listConsents(p)
|
||||
const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse'))
|
||||
const topics = () => [...aw.docs('workorders').values()].map((t) => String(t.topic))
|
||||
const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' })
|
||||
@@ -111,11 +112,26 @@ test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () =
|
||||
assert.match(tickets[0].details, /Funktion: analyse/)
|
||||
assert.match(tickets[0].details, /Domain\(s\): baeckerei\.project\.webklar\.com, baeckerei\.de/)
|
||||
assert.match(tickets[0].details, /https:\/\/baeckerei\.de\/datenschutz-analyse\.html/)
|
||||
assert.match(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/)
|
||||
assert.doesNotMatch(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/)
|
||||
for (const part of ['Textbaustein', 'offensichtlicher Widerspruch', 'externer Generator', 'Stundenkontingent', 'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies', 'Solange Sie im Banner nichts ausgewählt haben', 'Solange Du im Banner nichts ausgewählt hast']) {
|
||||
assert.ok(tickets[0].details.includes(part), part)
|
||||
}
|
||||
assert.ok(tickets[0].details.length < 8000, 'passt in workorders.details (8000)')
|
||||
assert.equal(topics().filter((t) => t.includes('veröffentlichen')).length, 0, 'Neubau lief: kein Veröffentlichen-Ticket')
|
||||
assert.equal((await doc('c1', 'p1')).privacyTicketId, tickets[0].$id)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(mails.sent[0].subject, 'Deine Website-Analyse ist aktiv – Bäckerei')
|
||||
assert.deepEqual(mails.sent[0].to.map((t) => t.address), ['eins@example.invalid'])
|
||||
const k18 = mails.sent[0]
|
||||
assert.deepEqual(k18.attachments.map((a) => a.filename), ['AVV-Website-Analyse-2026-10-02.pdf', 'Leistungsbeschreibung-Website-Analyse-2026-10-02.pdf'])
|
||||
for (const a of k18.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||||
assert.match(k18.html, /2026-10-02/)
|
||||
assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/avv-website-analyse-2026-10-02.html`))
|
||||
assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html`))
|
||||
assert.match(k18.html, /durch Kunde Eins <eins@example\.invalid>/)
|
||||
assert.match(k18.html, /\d\d\.\d\d\.\d{4}, \d\d:\d\d Uhr/)
|
||||
assert.match(k18.text, /Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung/)
|
||||
assert.doesNotMatch(k18.html, /in Textform am/)
|
||||
})
|
||||
|
||||
test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => {
|
||||
@@ -177,6 +193,11 @@ test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async (
|
||||
assert.equal(d.offSince, now.toISOString())
|
||||
}
|
||||
assert.deepEqual(mails.sent.map((m) => m.subject).sort(), ['Deine Website-Analyse wurde beendet – Hosting-Kunde', 'Deine Website-Analyse wurde beendet – Ohne Abo'])
|
||||
for (const m of mails.sent) {
|
||||
assert.match(m.text, /keine Daten mehr gespeichert/)
|
||||
assert.match(m.text, /46\. Tag/)
|
||||
assert.doesNotMatch(m.text, /Der Banner und die Messung sind von Deiner Website entfernt/)
|
||||
}
|
||||
mails.sent.length = 0
|
||||
assert.equal((await an.runAboCheck({ now })).deactivated.length, 0, 'zweiter Lauf: nichts mehr zu tun')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
@@ -204,18 +225,17 @@ test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau u
|
||||
const before = analyseTickets().length
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() }), /Ticketsystem down/)
|
||||
assert.equal((await doc('c1', 'p6')).state, 'active')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt)
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.deepEqual(rebuilds, ['p6', 'p6'], 'Neubau kommt vor dem Ticket, die Wiederholung baut erneut')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
// dritter Aufruf: nichts mehr offen
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.deepEqual(rebuilds, ['p6', 'p6'])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
an.analyseDeps.ticket = original
|
||||
})
|
||||
@@ -362,7 +382,7 @@ test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dok
|
||||
const { doc: d3 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW })
|
||||
assert.equal(d3.privacyTicketId && d3.privacyTicketId !== d1.privacyTicketId, true, 'neues Datenschutz-Ticket')
|
||||
assert.equal(d3.removalTicketId, '')
|
||||
assert.equal(d3.publishTicketId, '')
|
||||
assert.ok(d3.publishTicketId && d3.publishTicketId !== 'u1', 'p14 ohne Repo: neues Veröffentlichen-Ticket statt altem Marker')
|
||||
h = await consents('p14')
|
||||
assert.deepEqual(h.map((x) => x.event), ['on', 'off', 'on'])
|
||||
assert.deepEqual(h.map((x) => x.via), ['portal', 'portal', 'woms'])
|
||||
@@ -407,3 +427,100 @@ test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten
|
||||
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
|
||||
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off'])
|
||||
})
|
||||
|
||||
const staff = (extra = {}) => ({ consent: consent(), by: 'Kenso', textform: { date: '2026-10-03', channel: 'email' }, audienceConfirmed: true, now: NOW, ...extra })
|
||||
|
||||
test('K18 Mitarbeiter-Weg: in Textform am 03.10.2026 per E-Mail', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p20', customerId: 'c1', projectName: 'Textform', subdomain: 'textform', isPublic: true, repoFullName: 'WEBklar/textform' })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p20', ...staff() })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.match(mails.sent[0].html, /in Textform am 03\.10\.2026 per E-Mail/)
|
||||
assert.doesNotMatch(mails.sent[0].html, /Uhr/)
|
||||
})
|
||||
|
||||
test('K18: PDFs nicht lesbar => keine Mail, Marker leer, Aktivierung gültig, spätere Wiederholung sendet', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p21', customerId: 'c1', projectName: 'OhnePdf', subdomain: 'ohnepdf', isPublic: true, repoFullName: 'WEBklar/ohnepdf' })
|
||||
const { legalPdfAttachments } = await import('../server/services/analyseLegal.js')
|
||||
const ok = an.analyseDeps.legalPdfs
|
||||
an.analyseDeps.legalPdfs = () => legalPdfAttachments('1999-01-01')
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.equal(r.doc.activatedMailAt, '')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
an.analyseDeps.legalPdfs = ok
|
||||
const again = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() })
|
||||
assert.ok(again.doc.activatedMailAt)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = zwei', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p22', customerId: 'c1', projectName: 'Bäckerei Aus', subdomain: 'baeckereiaus', isPublic: true, repoFullName: 'WEBklar/aus' })
|
||||
const removal = () => topics().filter((t) => t === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...cust() })
|
||||
assert.equal(removal().length, 0)
|
||||
const off1 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor })
|
||||
assert.ok(off1.doc.removalTicketId)
|
||||
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor })
|
||||
assert.equal(removal().length, 1)
|
||||
const ticket = [...aw.docs('workorders').values()].find((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
assert.match(ticket.details, /fünf Werktagen/)
|
||||
assert.match(ticket.details, /Stundenkontingent/)
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...staff() })
|
||||
const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, by: 'Kenso' })
|
||||
assert.equal(removal().length, 2)
|
||||
assert.notEqual(off2.doc.removalTicketId, off1.doc.removalTicketId)
|
||||
})
|
||||
|
||||
test('Entfernen-Ticket: Ticketfehler verhindert das Ausschalten nicht', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p23', customerId: 'c1', projectName: 'TicketFehler', subdomain: 'ticketfehler', isPublic: true, repoFullName: 'WEBklar/tf' })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p23', ...cust() })
|
||||
const original = an.analyseDeps.ticket
|
||||
an.analyseDeps.ticket = async () => { throw new Error('Ticketsystem down') }
|
||||
const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p23', now: NOW, actor })
|
||||
an.analyseDeps.ticket = original
|
||||
assert.equal(r.doc.state, 'off')
|
||||
assert.equal(r.changed, true)
|
||||
})
|
||||
|
||||
test('Abo-Ende über runAboCheck: Entfernen-Ticket genau einmal', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p24', customerId: 'c3', projectName: 'Abo Ende', subdomain: 'aboende', isPublic: true, repoFullName: 'WEBklar/aboende' })
|
||||
const d = await sf.ensureFeatureDoc('c3', 'p24', 'analyse')
|
||||
await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' })
|
||||
await an.runAboCheck({ now: NOW })
|
||||
await an.runAboCheck({ now: NOW })
|
||||
assert.equal(topics().filter((t) => t === 'Website-Analyse von Abo Ende entfernen').length, 1)
|
||||
})
|
||||
|
||||
test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-Ticket; Erfolg => keins', async () => {
|
||||
const cases = [
|
||||
['p30', 'KeinRepo', {}, 'kein_repo'],
|
||||
['p31', 'PreviewAus', { repoFullName: 'WEBklar/pa', previewMode: 'off' }, 'preview_aus'],
|
||||
['p32', 'BauFehler', { repoFullName: 'WEBklar/bf' }, 'fehler'],
|
||||
['p33', 'BauErgebnisFehler', { repoFullName: 'WEBklar/bef' }, 'fehler'],
|
||||
['p34', 'BauOk', { repoFullName: 'WEBklar/bo' }, null],
|
||||
]
|
||||
const original = an.analyseDeps.rebuild
|
||||
for (const [id, name, extra, reason] of cases) {
|
||||
aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: name, subdomain: name.toLowerCase(), isPublic: true, ...extra })
|
||||
an.analyseDeps.rebuild = async (p) => {
|
||||
rebuilds.push(p.$id)
|
||||
if (id === 'p32') throw new Error('Runner weg')
|
||||
if (id === 'p33') return { error: 'Build kaputt' }
|
||||
return { ok: true }
|
||||
}
|
||||
rebuilds.length = 0
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() })
|
||||
const pub = [...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`)
|
||||
assert.equal(pub.length, reason ? 1 : 0, name)
|
||||
if (reason) {
|
||||
assert.equal(r.doc.publishTicketId, pub[0].$id)
|
||||
assert.ok(pub[0].details.includes(reason === 'kein_repo' ? 'kein Repo' : reason === 'preview_aus' ? 'ausgeschaltet' : 'fehlgeschlagen'), name)
|
||||
const privacy = [...aw.docs('workorders').values()].find((t) => t.topic === `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`)
|
||||
assert.match(privacy.details, /Veröffentlichung:/)
|
||||
}
|
||||
if (id === 'p30' || id === 'p31') assert.deepEqual(rebuilds, [], 'Rebuild-Stub nicht aufgerufen')
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() })
|
||||
assert.equal([...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`).length, reason ? 1 : 0, 'genau eines je Aktivierung')
|
||||
}
|
||||
an.analyseDeps.rebuild = original
|
||||
})
|
||||
Reference in new issue
Block a user