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:
1 parent
c6824f83a6
commit
3d05472b25
14 files changed
+585
-115
No files matched your search
+95
-11
@@ -2,6 +2,8 @@
|
||||
import { config } from '../config.js'
|
||||
import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { analyticsClient } from './analyticsClient.js'
|
||||
import { analysePrivacyData } from './analysePrivacy.js'
|
||||
import { recordConsent } from './analyseConsent.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { sendCustomerMail } from './mailer.js'
|
||||
import { triggerRebuild } from './previewRebuild.js'
|
||||
@@ -13,9 +15,12 @@ const FEATURE_KEY = 'analyse'
|
||||
const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
|
||||
const COLOR_RE = /^#[0-9a-f]{6}$/
|
||||
const DEFAULT_COLOR = '#111111'
|
||||
const CHANNELS = new Set(['chat', 'email'])
|
||||
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 }
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent }
|
||||
|
||||
function httpError(status, 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) {
|
||||
if (!project.repoFullName || project.previewMode === 'off') return
|
||||
try {
|
||||
@@ -139,9 +168,20 @@ function privacyTicket(customerId, project, siteKey) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
|
||||
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, now = new Date() }) {
|
||||
const viaCustomer = by === 'customer'
|
||||
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()
|
||||
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 changed = false
|
||||
if (existing.state !== 'active') {
|
||||
// Ohne Verantwortlichen-Angaben bliebe die Zusatzseite leer: nicht einschalten (kein Skript, kein Ticket, keine Mail)
|
||||
if (!analysePrivacyData(await customerOf(customerId)).ok) throw httpError(409, MISSING_PRIVACY_MESSAGE)
|
||||
const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey()
|
||||
// Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar)
|
||||
await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true })
|
||||
const 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, {
|
||||
...stateFields('active'),
|
||||
siteKey,
|
||||
accentColor: color,
|
||||
avvVersion: consent.avvVersion,
|
||||
avvAcceptedAt: now,
|
||||
avvAcceptedBy: String(by || 'customer').slice(0, 100),
|
||||
activatedAt: now,
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
avvAcceptedAt: at,
|
||||
avvAcceptedBy: accepted,
|
||||
avvConsentDate: proof.date,
|
||||
avvConsentChannel: proof.channel,
|
||||
activatedAt: at,
|
||||
offSince: '',
|
||||
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
|
||||
privacyTicketId: '', // und ihre Tickets
|
||||
removalTicketId: '',
|
||||
publishTicketId: '',
|
||||
})
|
||||
changed = true
|
||||
} 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 () => {
|
||||
const project = await loadOwnProject(customerId, projectId)
|
||||
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'),
|
||||
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)
|
||||
return { doc, changed: true }
|
||||
})
|
||||
@@ -229,7 +313,7 @@ export async function runAboCheck({ now = new Date() } = {}) {
|
||||
try {
|
||||
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
|
||||
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
|
||||
deactivated.push(doc.$id)
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({}))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
import { envKey, decryptEnv, maskLog } from './previewEnv.js'
|
||||
import { saveRunLog } from './previewLogs.js'
|
||||
import * as runnerClient from './previewRunnerClient.js'
|
||||
import { analysePrivacyData } from './analysePrivacy.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -730,17 +731,14 @@ async function buildAnalytics(project) {
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err
|
||||
}
|
||||
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const company = clean(customer?.companyName || customer?.name).slice(0, 200).trim()
|
||||
const email = clean(customer?.email)
|
||||
if (!company || !email || email.length > 200 || !email.includes('@')) {
|
||||
const privacy = analysePrivacyData(customer)
|
||||
if (!privacy.ok) {
|
||||
console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`)
|
||||
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 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) {
|
||||
console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`)
|
||||
return null
|
||||
|
||||
Reference in new issue
Block a user