Analyse: Einwilligungs-Historie, Nachweis beim Einschalten, Sperre ohne Verantwortlichen-Angaben, Textform für Mitarbeiter

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 c6824f83a6
commit 3d05472b25
14 files changed
+585 -115

No files matched your search

+106 -30
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an. * Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an und die
* Idempotent: vorhandene Attribute werden uebersprungen. * Collection analyseConsents (Einwilligungs-Historie, nur Server-Key). Idempotent: Vorhandenes
* wird uebersprungen. Vor dem Einschalten der Analyse (Livegang) ausfuehren.
* Usage: * Usage:
* node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen) * node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen)
* node scripts/analyse-setup.mjs (fehlende Attribute anlegen) * node scripts/analyse-setup.mjs (Fehlendes anlegen)
*/ */
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js' import { config } from '../server/config.js'
@@ -18,16 +19,37 @@ export const ANALYSE_ATTRIBUTES = [
{ key: 'offSince', type: 'string', size: 40, required: false }, { key: 'offSince', type: 'string', size: 40, required: false },
{ key: 'activatedMailAt', type: 'string', size: 40, required: false }, { key: 'activatedMailAt', type: 'string', size: 40, required: false },
{ key: 'privacyTicketId', type: 'string', size: 40, required: false }, { key: 'privacyTicketId', type: 'string', size: 40, required: false },
{ key: 'avvConsentDate', type: 'string', size: 10, required: false },
{ key: 'avvConsentChannel', type: 'string', size: 10, required: false },
{ key: 'removalTicketId', type: 'string', size: 40, required: false },
{ key: 'publishTicketId', type: 'string', size: 40, required: false },
] ]
/** Echter Appwrite-Client (REST) fuer die Collection portalFeatures. */ const str = (key, size) => ({ key, type: 'string', size, required: false })
/** Einwilligungs-Historie: nur anfuegen, nie aendern; kein Client-Zugriff. */
export const CONSENT_COLLECTION_NAME = 'analyseConsents'
export const CONSENT_ATTRIBUTES = [
str('customerId', 36),
str('projectId', 36),
str('siteKey', 40),
str('event', 8),
str('avvVersion', 20),
str('via', 8),
str('acceptedAt', 40),
str('acceptedBy', 100),
str('textformDate', 10),
str('textformChannel', 10),
]
export const CONSENT_INDEXES = [{ key: 'projectId_idx', type: 'key', attributes: ['projectId'] }]
/** Echter Appwrite-Client (REST); jede Methode nimmt die Collection-ID als ersten Parameter. */
export function createAppwriteClient() { export function createAppwriteClient() {
const { endpoint, projectId, databaseId, apiKey } = config.appwrite const { endpoint, projectId, databaseId, apiKey } = config.appwrite
const collectionId = config.collections.portalFeatures const db = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections`
const base = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections/${collectionId}`
async function aw(path, { method = 'GET', body } = {}) { async function aw(path, { method = 'GET', body } = {}) {
const response = await fetch(`${base}${path}`, { const response = await fetch(`${db}${path}`, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -37,60 +59,114 @@ export function createAppwriteClient() {
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.message || `Appwrite ${response.status}`) if (!response.ok) {
const err = new Error(data.message || `Appwrite ${response.status}`)
err.status = response.status
throw err
}
return data return data
} }
// Ohne limit liefert Appwrite nur 25 Eintraege
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }))
return { return {
async listAttributes() { async collectionExists(id) {
// Ohne limit liefert Appwrite nur 25 Attribute - die Sammlung hat mehr try {
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] })) await aw(`/${id}`)
const data = await aw(`/attributes?queries[]=${q}`) return true
return data.attributes || [] } catch (err) {
if (err.status === 404) return false
throw err
}
}, },
async createAttribute({ type, ...body }) { /** Legt die Collection ohne Rechte an (nur Server-Key); true = neu angelegt. */
await aw(`/attributes/${type}`, { method: 'POST', body }) async ensureCollection(id, name) {
if (await this.collectionExists(id)) return false
await aw('', { method: 'POST', body: { collectionId: id, name, permissions: [], documentSecurity: false } })
return true
}, },
async waitForAttribute(key, maxSec = 30) { async listAttributes(id) {
return (await aw(`/${id}/attributes?queries[]=${q}`)).attributes || []
},
async createAttribute(id, { type, ...body }) {
await aw(`/${id}/attributes/${type}`, { method: 'POST', body })
},
async waitForAttribute(id, key, maxSec = 30) {
for (let i = 0; i < maxSec; i += 1) { for (let i = 0; i < maxSec; i += 1) {
const attr = await aw(`/attributes/${key}`).catch(() => null) const attr = await aw(`/${id}/attributes/${key}`).catch(() => null)
if (attr?.status === 'available') return if (attr?.status === 'available') return
await new Promise((r) => setTimeout(r, 1000)) await new Promise((r) => setTimeout(r, 1000))
} }
throw new Error(`Attribut ${key} wurde nicht verfuegbar`) throw new Error(`Attribut ${key} wurde nicht verfuegbar`)
}, },
async listIndexes(id) {
return (await aw(`/${id}/indexes?queries[]=${q}`)).indexes || []
},
async createIndex(id, index) {
await aw(`/${id}/indexes`, { method: 'POST', body: index })
},
} }
} }
export async function main({ check = false, client, log = console.log } = {}) { export async function main({ check = false, client, log = console.log } = {}) {
const api = client || createAppwriteClient() const api = client || createAppwriteClient()
const existing = new Set((await api.listAttributes()).map((a) => a.key)) const featuresId = config.collections.portalFeatures
const missing = ANALYSE_ATTRIBUTES.filter((a) => !existing.has(a.key)) const consentsId = config.collections.analyseConsents
const missing = []
for (const attr of ANALYSE_ATTRIBUTES) { // Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen)
if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`) async function syncAttributes(collectionId, attributes, exists) {
else if (check) log(`- Attribut ${attr.key} FEHLT`) const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set()
const absent = attributes.filter((a) => !have.has(a.key))
for (const attr of attributes) {
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
}
return absent
} }
const featureAbsent = await syncAttributes(featuresId, ANALYSE_ATTRIBUTES, true)
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
const consentsExist = await api.collectionExists(consentsId)
if (!consentsExist) log(`${check ? '-' : '+'} Collection ${consentsId}${check ? ' FEHLT' : ''}`)
const consentAbsent = await syncAttributes(consentsId, CONSENT_ATTRIBUTES, consentsExist)
consentAbsent.forEach((a) => missing.push({ ...a, collection: consentsId }))
const haveIndexes = consentsExist ? new Set((await api.listIndexes(consentsId)).map((i) => i.key)) : new Set()
const indexAbsent = CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key))
if (check) indexAbsent.forEach((i) => log(`- Index ${consentsId}.${i.key} FEHLT`))
if (check) { if (check) {
log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.') const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1)
return { missing } log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
} }
for (const attr of missing) { for (const attr of featureAbsent) {
await api.createAttribute(attr) await api.createAttribute(featuresId, attr)
log(`+ Attribut ${attr.key}`) log(`+ Attribut ${featuresId}.${attr.key}`)
}
if (!consentsExist) await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME)
for (const attr of consentAbsent) {
await api.createAttribute(consentsId, attr)
log(`+ Attribut ${consentsId}.${attr.key}`)
}
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
for (const attr of consentAbsent) await api.waitForAttribute(consentsId, attr.key)
for (const index of indexAbsent) {
await api.createIndex(consentsId, index)
log(`+ Index ${consentsId}.${index.key}`)
} }
for (const attr of missing) await api.waitForAttribute(attr.key)
log('Fertig.') log('Fertig.')
return { missing } return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
} }
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) { if (invokedDirectly) {
const check = process.argv.includes('--check') const check = process.argv.includes('--check')
main({ check }) main({ check })
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0)) .then(({ missing, missingCollection, missingIndexes }) =>
process.exit(check && (missing.length || missingCollection || missingIndexes.length) ? 1 : 0))
.catch((e) => { .catch((e) => {
console.error(e.message) console.error(e.message)
process.exit(1) process.exit(1)
+2 -1
View File
@@ -143,6 +143,7 @@ export const config = {
customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess', customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess',
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects', websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures', portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders', workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets', worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees', employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
@@ -355,7 +356,7 @@ export const config = {
url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091', url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091',
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '', adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '', heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
avvVersion: '2026-10-01', avvVersion: '2026-10-02',
}, },
invoiceNinja: { invoiceNinja: {
+16 -6
View File
@@ -1,9 +1,9 @@
// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. AVV wird mündlich bestätigt. // Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. Einschalten nur mit Zustimmung des Kunden in Textform (Datum + Weg).
import { Router } from 'express' import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js' import { getDocument } from '../../services/appwriteAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { activateAnalyse, analyseEligibility, deactivateAnalyse, ELIGIBILITY_MESSAGES } from '../../services/analyse.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
import { getFeatureDoc } from '../../services/siteFeatures.js' import { getFeatureDoc } from '../../services/siteFeatures.js'
const router = Router() const router = Router()
@@ -26,9 +26,10 @@ router.get('/:projectId', async (req, res) => {
throw err throw err
} }
const customerId = project.customerId || '' const customerId = project.customerId || ''
const [doc, eligibility] = await Promise.all([ const [doc, eligibility, controller] = await Promise.all([
customerId ? getFeatureDoc(customerId, project.$id, 'analyse') : null, customerId ? getFeatureDoc(customerId, project.$id, 'analyse') : null,
analyseEligibility(customerId, project.$id), analyseEligibility(customerId, project.$id),
controllerView(customerId),
]) ])
res.json({ res.json({
enabled: config.analyse.enabled, enabled: config.analyse.enabled,
@@ -37,6 +38,9 @@ router.get('/:projectId', async (req, res) => {
avvVersion: config.analyse.avvVersion, avvVersion: config.analyse.avvVersion,
avvAcceptedAt: doc?.avvAcceptedAt || null, avvAcceptedAt: doc?.avvAcceptedAt || null,
avvAcceptedBy: doc?.avvAcceptedBy || null, avvAcceptedBy: doc?.avvAcceptedBy || null,
avvConsentDate: doc?.avvConsentDate || null,
avvConsentChannel: doc?.avvConsentChannel || null,
privacy: { ok: controller.ok, missing: controller.missing },
activatedAt: doc?.activatedAt || null, activatedAt: doc?.activatedAt || null,
offSince: doc?.offSince || null, offSince: doc?.offSince || null,
eligible: eligibility.ok, eligible: eligibility.ok,
@@ -50,10 +54,15 @@ router.get('/:projectId', async (req, res) => {
router.post('/:projectId', async (req, res) => { router.post('/:projectId', async (req, res) => {
try { try {
if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' }) if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' })
const { on, avvConfirmedVerbally } = req.body || {} const { on, textformDate, textformChannel, audienceConfirmed, avvConfirmedVerbally } = req.body || {}
if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' }) if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' })
if (on && avvConfirmedVerbally !== true) { let textform
return res.status(400).json({ error: 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat' }) if (on) {
if (avvConfirmedVerbally !== undefined) return res.status(400).json({ error: 'Zustimmung in Textform nötig' })
if (audienceConfirmed !== true) {
return res.status(400).json({ error: 'Bitte bestätigen, dass die Website sich nicht gezielt an Kinder unter 16 Jahren richtet' })
}
textform = validateTextform({ date: textformDate, channel: textformChannel })
} }
let project let project
try { try {
@@ -71,6 +80,7 @@ router.post('/:projectId', async (req, res) => {
projectId: project.$id, projectId: project.$id,
consent: { accepted: true, avvVersion: config.analyse.avvVersion }, consent: { accepted: true, avvVersion: config.analyse.avvVersion },
by, by,
textform,
}) })
: await deactivateAnalyse({ customerId, projectId: project.$id, by }) : await deactivateAnalyse({ customerId, projectId: project.$id, by })
res.json({ ok: true, state: doc?.state || 'off' }) res.json({ ok: true, state: doc?.state || 'off' })
+9 -1
View File
@@ -21,6 +21,7 @@ import {
analyseHosts, analyseHosts,
isDevPreview, isDevPreview,
analyseEligibility, analyseEligibility,
controllerView,
deactivateAnalyse, deactivateAnalyse,
} from '../services/analyse.js' } from '../services/analyse.js'
@@ -41,7 +42,11 @@ const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden.
async function analyseView(customerId, projectId) { async function analyseView(customerId, projectId) {
if (!config.analyse.enabled) return { comingSoon: true } if (!config.analyse.enabled) return { comingSoon: true }
const [doc, eligibility] = await Promise.all([getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId)]) const [doc, eligibility, controller] = await Promise.all([
getFeatureDoc(customerId, projectId, 'analyse'),
analyseEligibility(customerId, projectId),
controllerView(customerId),
])
let devMode = false let devMode = false
if (doc?.state === 'active') { if (doc?.state === 'active') {
const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null)) const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null))
@@ -55,6 +60,7 @@ async function analyseView(customerId, projectId) {
reason: eligibility.reason || null, reason: eligibility.reason || null,
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null, reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
avvVersion: config.analyse.avvVersion, avvVersion: config.analyse.avvVersion,
controller,
} }
} }
@@ -177,6 +183,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
accentColor, accentColor,
consent, consent,
by: 'customer', by: 'customer',
actor: { name: req.session.name, email: req.session.email },
}) })
res.json({ ok: true, state: doc.state }) res.json({ ok: true, state: doc.state })
} catch (err) { } catch (err) {
@@ -193,6 +200,7 @@ router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
customerId: getSessionCustomerId(req), customerId: getSessionCustomerId(req),
projectId: analyseProjectId(req.body?.projectId), projectId: analyseProjectId(req.body?.projectId),
by: 'customer', by: 'customer',
actor: { name: req.session.name, email: req.session.email },
}) })
res.json({ ok: true, state: doc?.state || 'off' }) res.json({ ok: true, state: doc?.state || 'off' })
} catch (err) { } catch (err) {
+95 -11
View File
@@ -2,6 +2,8 @@
import { config } from '../config.js' import { config } from '../config.js'
import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { analyticsClient } from './analyticsClient.js' import { analyticsClient } from './analyticsClient.js'
import { analysePrivacyData } from './analysePrivacy.js'
import { recordConsent } from './analyseConsent.js'
import { withLocks } from './keyedLock.js' import { withLocks } from './keyedLock.js'
import { sendCustomerMail } from './mailer.js' import { sendCustomerMail } from './mailer.js'
import { triggerRebuild } from './previewRebuild.js' import { triggerRebuild } from './previewRebuild.js'
@@ -13,9 +15,12 @@ const FEATURE_KEY = 'analyse'
const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
const COLOR_RE = /^#[0-9a-f]{6}$/ const COLOR_RE = /^#[0-9a-f]{6}$/
const DEFAULT_COLOR = '#111111' const DEFAULT_COLOR = '#111111'
const CHANNELS = new Set(['chat', 'email'])
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. */ /** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket } export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent }
function httpError(status, message) { function httpError(status, message) {
const err = new Error(message) const err = new Error(message)
@@ -91,6 +96,30 @@ async function customerOf(customerId) {
} }
} }
/** 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 }
}
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. Wirft 400, sonst { date, channel }.
*/
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) {
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, 'Weg der Zustimmung fehlt: Chat oder E-Mail')
return { date, channel }
}
async function rebuildSafe(project) { async function rebuildSafe(project) {
if (!project.repoFullName || project.previewMode === 'off') return if (!project.repoFullName || project.previewMode === 'off') return
try { try {
@@ -139,9 +168,20 @@ function privacyTicket(customerId, project, siteKey) {
}) })
} }
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) { export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, now = new Date() }) {
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) { const viaCustomer = by === 'customer'
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen') let accepted
let proof = { date: '', channel: '' }
if (viaCustomer) {
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
}
accepted = String(actor?.name || actor?.email || '').slice(0, 100)
if (!accepted) throw httpError(400, 'Bitte melde Dich neu an')
} else {
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() const color = accentColor === undefined || accentColor === null || accentColor === '' ? DEFAULT_COLOR : String(accentColor).toLowerCase()
if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe') if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe')
@@ -160,20 +200,47 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
let doc = existing let doc = existing
let changed = false let changed = false
if (existing.state !== 'active') { 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() const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey()
// Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar) // Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar)
await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true }) await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true })
const now = new Date().toISOString() const at = now.toISOString()
try {
await analyseDeps.recordConsent({
customerId,
projectId,
siteKey,
event: 'on',
avvVersion: config.analyse.avvVersion,
via: viaCustomer ? 'portal' : 'woms',
acceptedAt: at,
acceptedBy: accepted,
textformDate: proof.date,
textformChannel: proof.channel,
})
} catch (err) {
// Ohne Nachweis keine Aktivierung: Messdienst zuruecknehmen
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))
throw err
}
doc = await updateDocument(config.collections.portalFeatures, existing.$id, { doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
...stateFields('active'), ...stateFields('active'),
siteKey, siteKey,
accentColor: color, accentColor: color,
avvVersion: consent.avvVersion, avvVersion: config.analyse.avvVersion,
avvAcceptedAt: now, avvAcceptedAt: at,
avvAcceptedBy: String(by || 'customer').slice(0, 100), avvAcceptedBy: accepted,
activatedAt: now, avvConsentDate: proof.date,
avvConsentChannel: proof.channel,
activatedAt: at,
offSince: '', offSince: '',
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18 activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
privacyTicketId: '', // und ihre Tickets
removalTicketId: '',
publishTicketId: '',
}) })
changed = true changed = true
} else if (existing.accentColor !== color) { } else if (existing.accentColor !== color) {
@@ -197,7 +264,7 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
}) })
} }
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer' }) { export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) {
return withLocks([`analyse:${projectId}`], async () => { return withLocks([`analyse:${projectId}`], async () => {
const project = await loadOwnProject(customerId, projectId) const project = await loadOwnProject(customerId, projectId)
if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt) if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt)
@@ -212,6 +279,23 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
...stateFields('off'), ...stateFields('off'),
offSince: now.toISOString(), offSince: now.toISOString(),
}) })
try {
await analyseDeps.recordConsent({
customerId,
projectId,
siteKey: existing.siteKey || '',
event: 'off',
avvVersion: config.analyse.avvVersion,
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
acceptedAt: now.toISOString(),
acceptedBy: String((by === 'customer' && (actor?.name || actor?.email)) || by).slice(0, 100),
textformDate: '',
textformChannel: '',
})
} catch (err) {
// Ausschalten darf nie am Nachweis scheitern
console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
await rebuildSafe(project) await rebuildSafe(project)
return { doc, changed: true } return { doc, changed: true }
}) })
@@ -229,7 +313,7 @@ export async function runAboCheck({ now = new Date() } = {}) {
try { try {
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse // Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
if (await hasWartungsAbo(doc.customerId)) continue if (await hasWartungsAbo(doc.customerId)) continue
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now }) const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' })
if (!changed) continue if (!changed) continue
deactivated.push(doc.$id) deactivated.push(doc.$id)
const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({})) const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({}))
+15
View File
@@ -0,0 +1,15 @@
// Website-Analyse: Einwilligungs-Historie je Projekt (Collection analyseConsents).
// Nur anfuegen und lesen: es gibt bewusst keine Aenderungs- und Loeschfunktion.
import { config } from '../config.js'
import { createDocument, listDocuments, Query } from './appwriteAdmin.js'
/** @param {{customerId,projectId,siteKey,event,avvVersion,via,acceptedAt,acceptedBy,textformDate,textformChannel}} entry */
export async function recordConsent(entry) {
return createDocument(config.collections.analyseConsents, entry)
}
/** Alle Eintraege eines Projekts, aelteste zuerst. */
export async function listConsents(projectId) {
const docs = await listDocuments(config.collections.analyseConsents, [Query.equal('projectId', projectId), Query.limit(500)])
return [...docs].sort((a, b) => String(a.acceptedAt).localeCompare(String(b.acceptedAt)) || String(a.$createdAt).localeCompare(String(b.$createdAt)))
}
+20
View File
@@ -0,0 +1,20 @@
// Website-Analyse: Angaben des Verantwortlichen fuer die Datenschutz-Zusatzseite aus dem Kundendatensatz.
// Rein, ohne Imports. Einziger Regelsatz: Portal-Sperre, Portal-Vorschau und Runner-Auftrag nutzen ihn.
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim()
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* @returns {{ ok: boolean, company: string, address: string, email: string, missing: Array<'firma'|'email'> }}
*/
export function analysePrivacyData(customer) {
const c = customer || {}
const company = (clean(c.companyName) || clean(c.name)).slice(0, 200).trim()
const address = [clean(c.street), clean(`${c.postalCode || ''} ${c.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim()
const rawEmail = clean(c.email)
const email = rawEmail.slice(0, 200)
const missing = []
if (!company) missing.push('firma')
if (rawEmail.length > 200 || !EMAIL_RE.test(rawEmail)) missing.push('email')
return { ok: missing.length === 0, company, address, email, missing }
}
+4 -6
View File
@@ -10,6 +10,7 @@ import { giteaFetch, splitRepo } from './giteaClient.js'
import { envKey, decryptEnv, maskLog } from './previewEnv.js' import { envKey, decryptEnv, maskLog } from './previewEnv.js'
import { saveRunLog } from './previewLogs.js' import { saveRunLog } from './previewLogs.js'
import * as runnerClient from './previewRunnerClient.js' import * as runnerClient from './previewRunnerClient.js'
import { analysePrivacyData } from './analysePrivacy.js'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -730,17 +731,14 @@ async function buildAnalytics(project) {
} catch (err) { } catch (err) {
if (err.status !== 404) throw err if (err.status !== 404) throw err
} }
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() const privacy = analysePrivacyData(customer)
const company = clean(customer?.companyName || customer?.name).slice(0, 200).trim() if (!privacy.ok) {
const email = clean(customer?.email)
if (!company || !email || email.length > 200 || !email.includes('@')) {
console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`) console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`)
return null return null
} }
const address = [clean(customer.street), clean(`${customer.postalCode || ''} ${customer.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim()
const stored = String(feature.accentColor || '').toLowerCase() const stored = String(feature.accentColor || '').toLowerCase()
const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111' const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111'
return { siteKey: feature.siteKey, color, privacy: { company, address, email } } return { siteKey: feature.siteKey, color, privacy: { company: privacy.company, address: privacy.address, email: privacy.email } }
} catch (err) { } catch (err) {
console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`) console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`)
return null return null
+166 -21
View File
@@ -7,11 +7,13 @@ import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js' import { mailSink } from './support/mailSink.js'
// Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest // Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt'] const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId']
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel']
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { schema: {
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
subscriptions: schemaKeys('subscriptions'), subscriptions: schemaKeys('subscriptions'),
analyseConsents: CONSENT_KEYS,
}, },
}) })
const st = await startFakeStripe() const st = await startFakeStripe()
@@ -20,6 +22,8 @@ const { config } = await import('../server/config.js')
const { wartungDoc, hosting2Doc } = await import('./support/fixtures.js') const { wartungDoc, hosting2Doc } = await import('./support/fixtures.js')
const an = await import('../server/services/analyse.js') const an = await import('../server/services/analyse.js')
const sf = await import('../server/services/siteFeatures.js') const sf = await import('../server/services/siteFeatures.js')
const cl = await import('../server/services/analyseConsent.js')
const priv = await import('../server/services/analysePrivacy.js')
const { updateDocument } = await import('../server/services/appwriteAdmin.js') const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js') const { setMailTransportForTests } = await import('../server/services/mailer.js')
@@ -36,18 +40,28 @@ an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey,
an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id) an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id)
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
const actor = { name: 'Kunde Eins', email: 'eins@example.invalid' }
const cust = (extra = {}) => ({ consent: consent(), actor, ...extra })
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 analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse'))
const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' }) aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' })
aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' }) aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' })
aw.seed('customers', { $id: 'c4', name: '', email: 'vier@example.invalid' })
aw.seed('customers', { $id: 'c5', name: 'Kunde Fuenf', email: 'x' })
aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' }) aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' })
aw.seed('subscriptions', wartungDoc) aw.seed('subscriptions', wartungDoc)
aw.seed('subscriptions', hosting2Doc) aw.seed('subscriptions', hosting2Doc)
for (const c of ['c4', 'c5']) aw.seed('subscriptions', { ...wartungDoc, $id: `sub_w_${c}`, stripeSubscriptionId: `sub_w_${c}`, customerId: c })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' }) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true }) aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true })
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true }) aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true })
aw.seed('websiteProjects', { $id: 'p11', customerId: 'c4', projectName: 'Ohne Name', subdomain: 'ohnename', isPublic: true, repoFullName: 'WEBklar/ohnename' })
aw.seed('websiteProjects', { $id: 'p12', customerId: 'c5', projectName: 'Schlechte Mail', subdomain: 'schlechtemail', isPublic: true })
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true }) aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true })
beforeEach(() => { beforeEach(() => {
@@ -67,19 +81,19 @@ test('Berechtigung: Wartungs-Abo, öffentlich, Subdomain, Eigentum', async () =>
}) })
test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => { test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => {
await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', consent: consent() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', ...cust() }), { status: 403 })
await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', consent: consent() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', ...cust() }), { status: 403 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', consent: consent() }), { status: 409 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', ...cust() }), { status: 409 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', consent: consent() }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', ...cust() }), { status: 400 })
assert.equal(puts.length, 0) assert.equal(puts.length, 0)
assert.equal(analyseTickets().length, 0) assert.equal(analyseTickets().length, 0)
}) })
test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => { test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => {
const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', ...cust() })
assert.equal(changed, true) assert.equal(changed, true)
assert.equal(d.featureKey, 'analyse') assert.equal(d.featureKey, 'analyse')
assert.equal(d.state, 'active') assert.equal(d.state, 'active')
@@ -87,7 +101,7 @@ test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () =
assert.match(d.siteKey, /^[a-z0-9]{16,40}$/) assert.match(d.siteKey, /^[a-z0-9]{16,40}$/)
assert.equal(d.accentColor, '#1a73e8') assert.equal(d.accentColor, '#1a73e8')
assert.equal(d.avvVersion, config.analyse.avvVersion) assert.equal(d.avvVersion, config.analyse.avvVersion)
assert.equal(d.avvAcceptedBy, 'customer') assert.equal(d.avvAcceptedBy, 'Kunde Eins')
assert.ok(d.avvAcceptedAt && d.activatedAt) assert.ok(d.avvAcceptedAt && d.activatedAt)
assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de', 'www.baeckerei.de'], active: true }]) assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de', 'www.baeckerei.de'], active: true }])
assert.deepEqual(rebuilds, ['p1']) assert.deepEqual(rebuilds, ['p1'])
@@ -106,16 +120,17 @@ test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () =
test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => { test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => {
const before = await doc('c1', 'p1') const before = await doc('c1', 'p1')
const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', ...cust() })
assert.equal(same.changed, false) assert.equal(same.changed, false)
assert.equal(rebuilds.length, 0) assert.equal(rebuilds.length, 0)
const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', consent: consent() }) const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', ...cust() })
assert.equal(recolored.doc.accentColor, '#cc0000') assert.equal(recolored.doc.accentColor, '#cc0000')
assert.equal(recolored.doc.siteKey, before.siteKey) assert.equal(recolored.doc.siteKey, before.siteKey)
assert.deepEqual(rebuilds, ['p1']) assert.deepEqual(rebuilds, ['p1'])
assert.equal(analyseTickets().length, 1) assert.equal(analyseTickets().length, 1)
assert.equal(mails.sent.length, 0) assert.equal(mails.sent.length, 0)
assert.equal(puts.length, 0) assert.equal(puts.length, 0)
assert.equal((await consents('p1')).length, 1, 'Farbwechsel/zweites Aktivieren schreibt keine Historie')
}) })
test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => { test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => {
@@ -132,15 +147,17 @@ test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts'
await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 }) await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 })
}) })
test('Wieder einschalten: gleicher siteKey, offSince leer, kein neues Ticket', async () => { test('Wieder einschalten: gleicher siteKey, offSince leer, neues Ticket und Historie', async () => {
const before = await doc('c1', 'p1') const before = await doc('c1', 'p1')
const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso' }) const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso', textform, now: NOW })
assert.equal(d.siteKey, before.siteKey) assert.equal(d.siteKey, before.siteKey)
assert.equal(d.state, 'active') assert.equal(d.state, 'active')
assert.equal(d.offSince, '') assert.equal(d.offSince, '')
assert.equal(d.accentColor, '#111111') assert.equal(d.accentColor, '#111111')
assert.equal(d.avvAcceptedBy, 'Kenso') assert.equal(d.avvAcceptedBy, 'Kenso')
assert.equal(analyseTickets().length, 1) assert.equal(d.avvConsentDate, '2026-10-02')
assert.equal(d.avvConsentChannel, 'email')
assert.equal(analyseTickets().length, 2, 'neues Ticket nach Aus/An')
}) })
test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => { test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => {
@@ -185,18 +202,18 @@ test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau u
return original(...args) return original(...args)
} }
const before = analyseTickets().length const before = analyseTickets().length
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }), /Ticketsystem down/) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() }), /Ticketsystem down/)
assert.equal((await doc('c1', 'p6')).state, 'active') assert.equal((await doc('c1', 'p6')).state, 'active')
assert.equal(rebuilds.length, 0) assert.equal(rebuilds.length, 0)
assert.equal(mails.sent.length, 0) assert.equal(mails.sent.length, 0)
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
assert.equal(r.doc.state, 'active') assert.equal(r.doc.state, 'active')
assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt) assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt)
assert.equal(analyseTickets().length, before + 1) assert.equal(analyseTickets().length, before + 1)
assert.deepEqual(rebuilds, ['p6']) assert.deepEqual(rebuilds, ['p6'])
assert.equal(mails.sent.length, 1) assert.equal(mails.sent.length, 1)
// dritter Aufruf: nichts mehr offen // dritter Aufruf: nichts mehr offen
await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
assert.equal(analyseTickets().length, before + 1) assert.equal(analyseTickets().length, before + 1)
assert.deepEqual(rebuilds, ['p6']) assert.deepEqual(rebuilds, ['p6'])
assert.equal(mails.sent.length, 1) assert.equal(mails.sent.length, 1)
@@ -207,7 +224,7 @@ test('putSite scheitert: Dokument bleibt unverändert', async () => {
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true }) aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true })
const ok = an.analyseDeps.client const ok = an.analyseDeps.client
an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } } an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } }
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', consent: consent() }), { status: 502 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', ...cust() }), { status: 502 })
assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true) assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true)
assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0) assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0)
an.analyseDeps.client = ok an.analyseDeps.client = ok
@@ -217,7 +234,7 @@ test('Neubau scheitert: Analyse bleibt aktiv, kein Fehler', async () => {
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' }) aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' })
const ok = an.analyseDeps.rebuild const ok = an.analyseDeps.rebuild
an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') } an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') }
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', consent: consent() }) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', ...cust() })
assert.equal(r.doc.state, 'active') assert.equal(r.doc.state, 'active')
assert.equal(mails.sent.length, 1) assert.equal(mails.sent.length, 1)
an.analyseDeps.rebuild = ok an.analyseDeps.rebuild = ok
@@ -228,8 +245,8 @@ test('parallel einschalten: ein Ticket, eine Mail', async () => {
const before = analyseTickets().length const before = analyseTickets().length
mails.sent.length = 0 mails.sent.length = 0
await Promise.all([ await Promise.all([
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), an.activateAnalyse({ customerId: 'c1', projectId: 'p9', ...cust() }),
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), an.activateAnalyse({ customerId: 'c1', projectId: 'p9', ...cust() }),
]) ])
assert.equal(analyseTickets().length, before + 1) assert.equal(analyseTickets().length, before + 1)
assert.equal(mails.sent.length, 1) assert.equal(mails.sent.length, 1)
@@ -239,8 +256,8 @@ test('gesperrte Analyse: Kunde 403, Mitarbeiter darf', async () => {
aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true }) aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true })
const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse') const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse')
await sf.disable(g.$id, {}) await sf.disable(g.$id, {})
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', ...cust() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' })
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso' }) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso', textform, now: NOW })
assert.equal(r.doc.state, 'active') assert.equal(r.doc.state, 'active')
}) })
@@ -254,3 +271,131 @@ test('analyseDomains: www-/Apex-Gegenstück der Live-Domain, ohne Doppelte, max.
// gespeicherter Host bleibt die Live-Domain (Heatmap-Link, Ticket) // gespeicherter Host bleibt die Live-Domain (Heatmap-Link, Ticket)
assert.deepEqual(an.analyseHosts({ subdomain: 'x', liveDomain: 'example.de' }), ['x.project.webklar.com', 'example.de']) assert.deepEqual(an.analyseHosts({ subdomain: 'x', liveDomain: 'example.de' }), ['x.project.webklar.com', 'example.de'])
}) })
// ---- Rechtsrunde Task 5: Verantwortlichen-Angaben, Historie, Textform ----
test('analysePrivacyData: Firma/Name, E-Mail, Bereinigung, Adresse', () => {
const d = priv.analysePrivacyData
assert.deepEqual(d({ companyName: '', name: '', email: 'a@b.de' }).missing, ['firma'])
const nameOnly = d({ name: 'Max Muster', email: 'a@b.de' })
assert.equal(nameOnly.company, 'Max Muster')
assert.equal(nameOnly.ok, true)
assert.deepEqual(nameOnly.missing, [])
assert.deepEqual(d({ name: 'Max', email: 'abc' }).missing, ['email'])
assert.deepEqual(d({ name: '', email: '' }).missing, ['firma', 'email'])
assert.deepEqual(d(null).missing, ['firma', 'email'])
assert.equal(d({ companyName: 'Muster\nGmbH\t& Co', email: 'a@b.de' }).company, 'Muster GmbH & Co')
assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1' }).address, 'Weg 1')
assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1', postalCode: '12345', city: 'Berlin' }).address, 'Weg 1, 12345 Berlin')
assert.equal(d({ name: 'x'.repeat(300), email: 'a@b.de' }).company.length, 200)
assert.deepEqual(d({ name: 'M', email: `${'a'.repeat(200)}@b.de` }).missing, ['email'])
assert.deepEqual(d({ name: 'M', email: 'a b@c.de' }).missing, ['email'])
})
test('validateTextform: Datum, Zukunft, Fassung, Weg', () => {
const ctx = { today: '2026-10-05', minDate: '2026-10-02' }
const bad = (t) => assert.throws(() => an.validateTextform(t, ctx), { status: 400 })
bad({ date: '2026-10-01', channel: 'chat' })
bad({ date: '2026-10-06', channel: 'chat' })
bad({ date: '2026-02-30', channel: 'chat' })
bad({ date: '02.10.2026', channel: 'chat' })
bad({ date: '2026-10-02', channel: 'telefon' })
bad({ date: '2026-10-02' })
bad(undefined)
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' })
assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' })
})
test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => {
const before = analyseTickets().length
for (const [c, p] of [['c4', 'p11'], ['c5', 'p12']]) {
await assert.rejects(an.activateAnalyse({ customerId: c, projectId: p, ...cust() }), {
status: 409,
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.',
})
assert.equal((await doc(c, p)).state, 'off')
}
assert.equal(puts.length, 0)
assert.equal(analyseTickets().length, before)
assert.equal(mails.sent.length, 0)
assert.equal((await consents('p11')).length, 0)
})
test('Kunde ohne actor-Namen oder Mitarbeiter ohne Textform: 400', async () => {
aw.seed('websiteProjects', { $id: 'p13', customerId: 'c1', projectName: 'Nachweis', subdomain: 'nachweis', isPublic: true })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent() }), { status: 400 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), actor: {} }), { status: 400 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), by: 'Kenso', now: NOW }), { status: 400 })
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', by: 'Kenso', textform: { date: '2026-10-01', channel: 'chat' }, now: NOW }), { status: 400 })
assert.equal(puts.length, 0)
assert.equal((await consents('p13')).length, 0)
})
test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dokument gleich', async () => {
aw.seed('websiteProjects', { $id: 'p14', customerId: 'c1', projectName: 'Historie', subdomain: 'historie', isPublic: true })
const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
let h = await consents('p14')
assert.equal(h.length, 1)
assert.equal(h[0].event, 'on')
assert.equal(h[0].via, 'portal')
assert.equal(h[0].avvVersion, '2026-10-02')
assert.equal(h[0].acceptedBy, 'Kunde Eins')
assert.equal(h[0].customerId, 'c1')
assert.equal(h[0].siteKey, d1.siteKey)
assert.match(h[0].acceptedAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
assert.equal(h[0].acceptedAt, d1.avvAcceptedAt)
assert.equal(d1.avvAcceptedBy, 'Kunde Eins')
assert.equal(d1.avvConsentDate, '')
assert.equal(d1.avvConsentChannel, '')
// Farbwechsel und erneutes Aktivieren: keine neue Zeile
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust() })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
assert.equal((await consents('p14')).length, 1)
// Marker zurücksetzen beim Wiedereinschalten
await updateDocument(config.collections.portalFeatures, d1.$id, { removalTicketId: 'r1', publishTicketId: 'u1' })
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p14', now: NOW, actor })
const { doc: d3 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', consent: consent(), by: 'Kenso', textform, now: NOW })
assert.equal(d3.privacyTicketId && d3.privacyTicketId !== d1.privacyTicketId, true, 'neues Datenschutz-Ticket')
assert.equal(d3.removalTicketId, '')
assert.equal(d3.publishTicketId, '')
h = await consents('p14')
assert.deepEqual(h.map((x) => x.event), ['on', 'off', 'on'])
assert.deepEqual(h.map((x) => x.via), ['portal', 'portal', 'woms'])
assert.equal(h[1].acceptedBy, 'Kunde Eins')
assert.equal(h[2].acceptedBy, 'Kenso')
assert.equal(h[2].textformDate, '2026-10-02')
assert.equal(h[2].textformChannel, 'email')
assert.equal(d3.avvConsentDate, '2026-10-02')
assert.equal(d3.avvAcceptedBy, 'Kenso')
})
test('analyseConsent exportiert nur recordConsent und listConsents', () => {
assert.deepEqual(Object.keys(cl).sort(), ['listConsents', 'recordConsent'])
})
test('recordConsent scheitert: putSite wird zurückgenommen, Dokument bleibt aus', async () => {
aw.seed('websiteProjects', { $id: 'p15', customerId: 'c1', projectName: 'Rollback', subdomain: 'rollback', isPublic: true })
const prev = an.analyseDeps.recordConsent
an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') }
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p15', ...cust() }), /Historie weg/)
an.analyseDeps.recordConsent = prev
assert.equal(puts.length, 2)
assert.equal(puts[0].active, true)
assert.equal(puts[1].active, false)
assert.equal((await doc('c1', 'p15')).state, 'off')
assert.equal(analyseTickets().filter((t) => t.details.includes('Rollback')).length, 0)
})
test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten nicht', async () => {
aw.seed('websiteProjects', { $id: 'p16', customerId: 'c1', projectName: 'Aus', subdomain: 'aus', isPublic: true })
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
const prev = an.analyseDeps.recordConsent
an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') }
const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
an.analyseDeps.recordConsent = prev
assert.equal(r.doc.state, 'off')
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on'])
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off'])
})
+60 -14
View File
@@ -6,14 +6,15 @@ import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js' import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js' import { mailSink } from './support/mailSink.js'
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt'] const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId']
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel']
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions') }, schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions'), analyseConsents: CONSENT_KEYS },
}) })
const st = await startFakeStripe() const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js') const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js') const { startTestApp, get, rawGet, post, sessionCookie } = await import('./support/testApp.js')
const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js') const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js') const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js')
@@ -46,6 +47,8 @@ an.analyseDeps.client = {
an.analyseDeps.rebuild = async () => {} an.analyseDeps.rebuild = async () => {}
config.analyse.enabled = true config.analyse.enabled = true
config.analyse.heatSecret = 'h'.repeat(32) config.analyse.heatSecret = 'h'.repeat(32)
// Die Fassung 2026-10-02 liegt (je nach Uhr) noch in der Zukunft: Textform-Mindestdatum fuer die Tests auf frueh setzen
config.analyse.avvVersion = '2000-01-01'
seedFixtures(aw) seedFixtures(aw)
aw.seed('subscriptions', wartungDoc) aw.seed('subscriptions', wartungDoc)
@@ -60,6 +63,9 @@ const cookie = customerCookie('c1')
const A = '/api/funktionen/analyse' const A = '/api/funktionen/analyse'
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } }) const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } })
const todayBerlin = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date())
const tf = (extra = {}) => ({ textformDate: todayBerlin(), textformChannel: 'chat', audienceConfirmed: true, ...extra })
const consentsOf = (p) => [...aw.docs('analyseConsents').values()].filter((d) => d.projectId === p).sort((a, b) => a.$seq - b.$seq)
const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
beforeEach(() => { beforeEach(() => {
@@ -89,7 +95,7 @@ test('Schalter aus: alle Analyse-Routen 409', async () => {
post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }), post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }),
rawGet(`${A}/stats?projectId=p1`, { cookie }), rawGet(`${A}/stats?projectId=p1`, { cookie }),
post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }), post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }),
adminPost('p1', { on: true, avvConfirmedVerbally: true }), adminPost('p1', { on: true, ...tf() }),
]) { ]) {
const res = await r const res = await r
assert.equal(res.status, 409) assert.equal(res.status, 409)
@@ -102,7 +108,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a
const body = await get('/api/funktionen', { cookie }) const body = await get('/api/funktionen', { cookie })
const p = (id) => body.projects.find((x) => x.projectId === id) const p = (id) => body.projects.find((x) => x.projectId === id)
assert.equal(p('p1').features.news.state, 'off') assert.equal(p('p1').features.news.state, 'off')
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion }) assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } })
assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.eligible, false)
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
@@ -189,18 +195,34 @@ test('Heatmap-Link: Prüfung, URL, Token', async () => {
test('Admin: 401, 400, 200', async () => { test('Admin: 401, 400, 200', async () => {
assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401) assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401)
assert.equal((await adminPost('p6', { on: true, avvConfirmedVerbally: true }, 'jwt-kunde')).status, 403) assert.equal((await adminPost('p6', { on: true, ...tf() }, 'jwt-kunde')).status, 403)
const noAvv = await adminPost('p6', { on: true }) const noTf = await adminPost('p6', { on: true, audienceConfirmed: true, textformChannel: 'chat' })
assert.equal(noAvv.status, 400) assert.equal(noTf.status, 400)
assert.equal(noAvv.body.error, 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat') assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: undefined }) })).status, 400)
assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: undefined }) })).status, 400)
assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: 'telefon' }) })).status, 400)
assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: '1999-01-01' }) })).status, 400, 'vor der Fassung')
assert.equal((await adminPost('p6', { on: true, ...tf({ audienceConfirmed: undefined }) })).status, 400)
const old = await adminPost('p6', { on: true, avvConfirmedVerbally: true, ...tf() })
assert.equal(old.status, 400)
assert.equal(old.body.error, 'Zustimmung in Textform nötig')
assert.equal(await featureDoc('c1', 'p6'), null, 'nichts angelegt')
assert.equal((await adminPost('p6', { on: 'ja' })).status, 400) assert.equal((await adminPost('p6', { on: 'ja' })).status, 400)
assert.equal((await adminPost('nix', { on: true, avvConfirmedVerbally: true })).status, 404) assert.equal((await adminPost('nix', { on: true, ...tf() })).status, 404)
const on = await adminPost('p6', { on: true, avvConfirmedVerbally: true }) const on = await adminPost('p6', { on: true, ...tf() })
assert.equal(on.status, 200) assert.equal(on.status, 200)
assert.deepEqual(on.body, { ok: true, state: 'active' }) assert.deepEqual(on.body, { ok: true, state: 'active' })
const d = await featureDoc('c1', 'p6') const d = await featureDoc('c1', 'p6')
assert.equal(d.avvAcceptedBy, 'kenso@webklar.com') assert.equal(d.avvAcceptedBy, 'kenso@webklar.com')
assert.equal(d.avvVersion, config.analyse.avvVersion) assert.equal(d.avvVersion, config.analyse.avvVersion)
assert.equal(d.avvConsentDate, todayBerlin())
assert.equal(d.avvConsentChannel, 'chat')
const h = consentsOf('p6')
assert.equal(h.length, 1)
assert.equal(h[0].via, 'woms')
assert.equal(h[0].acceptedBy, 'kenso@webklar.com')
assert.equal(h[0].textformDate, todayBerlin())
assert.equal(h[0].textformChannel, 'chat')
const off = await adminPost('p6', { on: false }) const off = await adminPost('p6', { on: false })
assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.deepEqual(off.body, { ok: true, state: 'off' })
}) })
@@ -217,9 +239,12 @@ test('Admin: Lesezugriff GET', async () => {
assert.equal(off.body.eligible, true) assert.equal(off.body.eligible, true)
assert.equal(off.body.reasonText, null) assert.equal(off.body.reasonText, null)
assert.equal(off.body.avvVersion, config.analyse.avvVersion) assert.equal(off.body.avvVersion, config.analyse.avvVersion)
await adminPost('p1', { on: true, avvConfirmedVerbally: true }) await adminPost('p1', { on: true, ...tf() })
const on = await adminGet('p1') const on = await adminGet('p1')
assert.equal(on.body.state, 'active') assert.equal(on.body.state, 'active')
assert.equal(on.body.avvConsentDate, todayBerlin())
assert.equal(on.body.avvConsentChannel, 'chat')
assert.deepEqual(on.body.privacy, { ok: true, missing: [] })
assert.equal(on.body.avvAcceptedBy, 'kenso@webklar.com') assert.equal(on.body.avvAcceptedBy, 'kenso@webklar.com')
assert.ok(on.body.activatedAt) assert.ok(on.body.activatedAt)
assert.ok(!('siteKey' in on.body) && !JSON.stringify(on.body).includes((await featureDoc('c1', 'p1')).siteKey)) assert.ok(!('siteKey' in on.body) && !JSON.stringify(on.body).includes((await featureDoc('c1', 'p1')).siteKey))
@@ -230,7 +255,7 @@ test('Admin: Lesezugriff GET', async () => {
}) })
test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => { test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => {
await adminPost('p6', { on: true, avvConfirmedVerbally: true }) await adminPost('p6', { on: true, ...tf() })
const cur = await featureDoc('c1', 'p6') const cur = await featureDoc('c1', 'p6')
await sf.disable(cur.$id, { by: 'Test' }) await sf.disable(cur.$id, { by: 'Test' })
puts.length = 0 puts.length = 0
@@ -242,7 +267,7 @@ test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async
}) })
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => { test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => {
await adminPost('p6', { on: true, avvConfirmedVerbally: true }) await adminPost('p6', { on: true, ...tf() })
const cur = await featureDoc('c1', 'p6') const cur = await featureDoc('c1', 'p6')
puts.length = 0 puts.length = 0
await sf.disable(cur.$id, { by: 'Test' }) await sf.disable(cur.$id, { by: 'Test' })
@@ -275,3 +300,24 @@ test('Härtung: ungültige Hosts, Pfadzeichen, Fehler bei einem Projekt', async
assert.equal(bad.body.error, 'Keine gültige Domain für die Analyse') assert.equal(bad.body.error, 'Keine gültige Domain für die Analyse')
assert.equal(puts.length, before) assert.equal(puts.length, before)
}) })
test('Kunden-Aktivierung schreibt Historie mit Sitzungsnamen; fehlende Angaben: controller + 409', async () => {
aw.seed('customers', { $id: 'c9', appwriteUserId: 'u9', email: 'neun', name: '', status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Leer', subdomain: 'leer', isPublic: true })
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_w_c9', stripeSubscriptionId: 'sub_w_c9', customerId: 'c9' })
const c9 = sessionCookie({ customerId: 'c9', appwriteUserId: 'u9', name: 'Neun', email: 'neun' })
const view = (await get('/api/funktionen', { cookie: c9 })).projects.find((x) => x.projectId === 'p9').analyse
assert.deepEqual(view.controller, { company: '', address: '', email: 'neun', ok: false, missing: ['firma', 'email'] })
const r = await post(`${A}/activate`, { projectId: 'p9', consent: consent() }, { cookie: c9 })
assert.equal(r.status, 409)
assert.match(r.body.error, /fehlen in Deinen Kundendaten Firma oder Name/)
assert.equal(consentsOf('p9').length, 0)
const before = consentsOf('p3').length
await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
const h = consentsOf('p3')
assert.equal(h.length, before + 1)
assert.equal(h.at(-1).acceptedBy, 'Kunde Eins')
assert.equal(h.at(-1).via, 'portal')
const admin = (await rawGet('/api/admin/analyse/p9', { headers: { Authorization: 'Bearer jwt-admin' } })).body
assert.deepEqual(admin.privacy, { ok: false, missing: ['firma', 'email'] })
})
+64 -20
View File
@@ -1,23 +1,36 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { ANALYSE_ATTRIBUTES, main } from '../scripts/analyse-setup.mjs' import { ANALYSE_ATTRIBUTES, CONSENT_ATTRIBUTES, CONSENT_INDEXES, main } from '../scripts/analyse-setup.mjs'
import { config } from '../server/config.js' import { config } from '../server/config.js'
function mockClient(existing = []) { function mockClient({ attrs = [], consentsExist = false, consentAttrs = [], indexes = [] } = {}) {
const created = [] const calls = { attrs: [], collections: [], indexes: [] }
return { return {
created, calls,
async listAttributes() { async collectionExists() {
return existing.map((key) => ({ key })) return consentsExist
}, },
async createAttribute(attr) { async ensureCollection(id, name) {
created.push(attr.key) calls.collections.push({ id, name })
return true
},
async listAttributes(id) {
return (id === config.collections.portalFeatures ? attrs : consentAttrs).map((key) => ({ key }))
},
async createAttribute(id, attr) {
calls.attrs.push(`${id}.${attr.key}`)
}, },
async waitForAttribute() {}, async waitForAttribute() {},
async listIndexes() {
return indexes.map((key) => ({ key }))
},
async createIndex(id, index) {
calls.indexes.push(`${id}.${index.key}`)
},
} }
} }
test('Schema: 8 Attribute mit Groessen, alle optional', () => { test('Schema: 12 Attribute mit Groessen, alle optional', () => {
const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.map((a) => [a.key, a.size])) const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.map((a) => [a.key, a.size]))
assert.deepEqual(sizes, { assert.deepEqual(sizes, {
accentColor: 7, accentColor: 7,
@@ -28,23 +41,54 @@ test('Schema: 8 Attribute mit Groessen, alle optional', () => {
offSince: 40, offSince: 40,
privacyTicketId: 40, privacyTicketId: 40,
activatedMailAt: 40, activatedMailAt: 40,
avvConsentDate: 10,
avvConsentChannel: 10,
removalTicketId: 40,
publishTicketId: 40,
}) })
assert.equal(ANALYSE_ATTRIBUTES.length, 12)
assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false && a.type === 'string')) assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false && a.type === 'string'))
}) })
test('main legt nur fehlende Attribute an', async () => { test('Schema: Historien-Collection analyseConsents', () => {
const client = mockClient(['accentColor']) assert.equal(config.collections.analyseConsents, 'analyseConsents')
const result = await main({ client, log: () => {} }) const sizes = Object.fromEntries(CONSENT_ATTRIBUTES.map((a) => [a.key, a.size]))
assert.equal(client.created.length, 7) assert.deepEqual(sizes, {
assert.ok(!client.created.includes('accentColor')) customerId: 36, projectId: 36, siteKey: 40, event: 8, avvVersion: 20, via: 8,
assert.equal(result.missing.length, 7) acceptedAt: 40, acceptedBy: 100, textformDate: 10, textformChannel: 10,
})
assert.deepEqual(CONSENT_INDEXES.map((i) => i.attributes), [['projectId']])
}) })
test('main --check legt nichts an', async () => { test('main legt nur fehlende Attribute an und die fehlende Collection samt Index', async () => {
const client = mockClient(['accentColor']) const client = mockClient({ attrs: ['accentColor'] })
const result = await main({ client, log: () => {} })
assert.equal(client.calls.attrs.filter((k) => k.startsWith('portalFeatures.')).length, 11)
assert.ok(!client.calls.attrs.includes('portalFeatures.accentColor'))
assert.equal(client.calls.attrs.filter((k) => k.startsWith('analyseConsents.')).length, 10)
assert.deepEqual(client.calls.collections, [{ id: 'analyseConsents', name: 'analyseConsents' }])
assert.deepEqual(client.calls.indexes, ['analyseConsents.projectId_idx'])
assert.equal(result.missing.length, 21)
})
test('main ueberspringt vorhandene Collection, Attribute und Index', async () => {
const client = mockClient({
attrs: ANALYSE_ATTRIBUTES.map((a) => a.key),
consentsExist: true,
consentAttrs: CONSENT_ATTRIBUTES.map((a) => a.key),
indexes: ['projectId_idx'],
})
const result = await main({ client, log: () => {} })
assert.deepEqual(client.calls, { attrs: [], collections: [], indexes: [] })
assert.equal(result.missing.length, 0)
})
test('main --check legt nichts an und meldet missing', async () => {
const client = mockClient({ attrs: ['accentColor'] })
const result = await main({ check: true, client, log: () => {} }) const result = await main({ check: true, client, log: () => {} })
assert.equal(client.created.length, 0) assert.deepEqual(client.calls, { attrs: [], collections: [], indexes: [] })
assert.equal(result.missing.length, 7) assert.equal(result.missing.length, 21)
assert.equal(result.missingCollection, true)
}) })
test('Konfiguration: analyse ist standardmaessig aus', () => { test('Konfiguration: analyse ist standardmaessig aus', () => {
@@ -52,5 +96,5 @@ test('Konfiguration: analyse ist standardmaessig aus', () => {
assert.equal(config.analyse.url, 'http://webklar-analytics:8091') assert.equal(config.analyse.url, 'http://webklar-analytics:8091')
assert.equal(config.analyse.adminToken, '') assert.equal(config.analyse.adminToken, '')
assert.equal(config.analyse.heatSecret, '') assert.equal(config.analyse.heatSecret, '')
assert.equal(config.analyse.avvVersion, '2026-10-01') assert.equal(config.analyse.avvVersion, '2026-10-02')
}) })
+15 -4
View File
@@ -2,13 +2,24 @@ import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
const { config } = await import('../server/config.js')
const root = (rel) => new URL(`../${rel}`, import.meta.url) const root = (rel) => new URL(`../${rel}`, import.meta.url)
test('AVV Website-Analyse: Entwurf ist nicht öffentlich (geprüfte Fassung kommt mit der Rechtsrunde)', () => { test('AVV Website-Analyse: öffentliche Seite existiert, trägt Fassung und Entwurfshinweis, keine internen Kommentare', () => {
assert.ok(!existsSync(root('public/rechtliches/avv-website-analyse.html'))) const file = root('public/rechtliches/avv-website-analyse.html')
assert.ok(existsSync(file))
const html = readFileSync(file, 'utf8')
// Die Entwurfsseite nennt noch Fassung 2026-10-01; Task 6 ersetzt sie durch die geprüfte Fassung 2026-10-02
assert.ok(html.includes('Fassung 2026-10-01'))
assert.match(html, /Entwurf – vor Verwendung anwaltlich prüfen lassen/)
assert.doesNotMatch(html, /<!--/)
assert.match(html, /<h2>12\. Haftung/)
})
test('AVV-Link im Portal zeigt auf eine vorhandene Datei unter public/', () => {
const fn = readFileSync(root('public/funktionen.js'), 'utf8') const fn = readFileSync(root('public/funktionen.js'), 'utf8')
assert.equal(fn.match(/const AVV_URL = '([^']+)'/)?.[1], '/rechtliches/avv-website-analyse.html') const url = fn.match(/const AVV_URL = '([^']+)'/)?.[1]
assert.equal(url, '/rechtliches/avv-website-analyse.html')
assert.ok(existsSync(root(`public${url}`)))
}) })
test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => { test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => {
+7
View File
@@ -403,6 +403,13 @@ test('Analyse: Standardfarbe, Firma fällt auf Name zurück, Adresse optional',
assert.equal(body.analytics.privacy.address, '') assert.equal(body.analytics.privacy.address, '')
}) })
test('Analyse: ungültige E-Mail ⇒ null, nur Name reicht als Firma', async (t) => {
t.mock.method(console, 'warn', () => {})
assert.equal((await submitFor(await analyseProject({ customer: { email: 'abc' } }))).analytics, null)
const body = await submitFor(await analyseProject({ customer: { companyName: '', name: 'Nur Name' } }))
assert.equal(body.analytics.privacy.company, 'Nur Name')
})
test('Analyse: aus ⇒ null', async () => { test('Analyse: aus ⇒ null', async () => {
assert.equal((await submitFor(await analyseProject({ feature: { state: 'off', enabled: false } }))).analytics, null) assert.equal((await submitFor(await analyseProject({ feature: { state: 'off', enabled: false } }))).analytics, null)
assert.equal((await submitFor(await analyseProject({ noFeature: true }))).analytics, null) assert.equal((await submitFor(await analyseProject({ noFeature: true }))).analytics, null)
+6 -1
View File
@@ -85,7 +85,12 @@ export const consent = {
/** Kunden-Session; Standard c1. */ /** Kunden-Session; Standard c1. */
export const customerCookie = (customerId = 'c1') => export const customerCookie = (customerId = 'c1') =>
sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` }) sessionCookie({
customerId,
appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}`,
name: customers[customerId]?.name || '', // wie beim echten Login (auth.js)
email: customers[customerId]?.email || '',
})
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */ /** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
export const adminCookie = () => export const adminCookie = () =>