diff --git a/server/mail-templates/K18 Analyse aktiv.html b/server/mail-templates/K18 Analyse aktiv.html index 2f85321..93ddf5c 100644 --- a/server/mail-templates/K18 Analyse aktiv.html +++ b/server/mail-templates/K18 Analyse aktiv.html @@ -7,7 +7,8 @@ Betreff: Deine Website-Analyse ist aktiv – {{projekt}} Absender: „WEBklar“ 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 --> @@ -33,8 +34,15 @@ a{color:#111111;} - - + +
✓  Website-Analyse
Deine Website-Analyse ist aktiv
Hallo {{kunde_name}},
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.
FunktionWebsite-Analyse
Projekt{{projekt}}
Hallo {{kunde_name}},
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.
+ + + + + + +
FunktionWebsite-Analyse
Projekt{{projekt}}
VereinbartAuftragsverarbeitungsvertrag Website-Analyse und Leistungsbeschreibung Website-Analyse, Fassung {{fassung}} (beide als PDF im Anhang und verlinkt)
Zugestimmt{{zeitpunkt}} durch {{person}}
Zugestimmtin Textform am {{datum}} per {{weg}}
Auswertung öffnen →
Viele Grüße
Dein WEBklar-Team
diff --git a/server/mail-templates/K19 Analyse beendet.html b/server/mail-templates/K19 Analyse beendet.html index ad9b285..60c5774 100644 --- a/server/mail-templates/K19 Analyse beendet.html +++ b/server/mail-templates/K19 Analyse beendet.html @@ -33,7 +33,7 @@ a{color:#111111;} - + diff --git a/server/services/analyse.js b/server/services/analyse.js index 04a36b3..87fb713 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -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 } }) } diff --git a/server/services/analyseTexts.js b/server/services/analyseTexts.js new file mode 100644 index 0000000..9b61477 --- /dev/null +++ b/server/services/analyseTexts.js @@ -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') +} diff --git a/test/analyse.test.js b/test/analyse.test.js index 1ed2ade..19f2173 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -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 +})
Website-Analyse
Deine Website-Analyse wurde beendet
Hallo {{kunde_name}},
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.
Hallo {{kunde_name}},
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.
FunktionWebsite-Analyse
Projekt{{projekt}}
Zum Kundenportal →
Viele Grüße
Dein WEBklar-Team