// Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key). // Nur anfuegen und lesen: es gibt bewusst keine Aenderungs- und Loeschfunktion (wie analyseConsent.js). // Gespeichert wird, was dem Kunden gezeigt wurde: Texte in Anzeigereihenfolge, die Fassungen der Unterlagen, der // Zeitpunkt des Klicks und ein SHA-256 ueber die Texte. Vollstaendig oder gar nicht: nichts wird gekuerzt. import { createHash } from 'node:crypto' import { config } from '../config.js' import { createDocument, listDocuments, Query } from './appwriteAdmin.js' // Grenzen der Collection (scripts/feature-setup.mjs, FEATURE_CONSENT_ATTRIBUTES) const MAX_ID = 36 const MAX_FEATURE_KEY = 16 const MAX_SESSION = 128 const MAX_FASSUNGEN = 1000 const MAX_TEXTE = 20000 const MAX_IP = 64 const MAX_USER_AGENT = 300 const MAX_KONTO = 255 // Mehr Nachweise je Sitzung gibt es nur, wenn ein Speichern wiederholt wurde; gelesen wird eine kleine Seite const SITZUNG_SEITE = 25 function httpError(status, message) { const err = new Error(message) err.status = status return err } /** Kennung (Kunde, Projekt, Funktion, Sitzung): wird nie gekuerzt, sondern abgelehnt. */ function kennung(value, max, name, { optional = false } = {}) { if (optional && (value === undefined || value === null || value === '')) return '' if (typeof value !== 'string' || !value.trim() || value.length > max) throw httpError(400, `Zustimmungsnachweis: ${name} fehlt oder ist ungültig`) return value } /** Freie Angaben aus der Anfrage (IP, Browser, Konto): fehlt etwas, bleibt es leer; zu Langes wird gekuerzt. */ const kurz = (value, max) => String(value ?? '').slice(0, max) function zeitpunkt(at) { const ms = at instanceof Date ? at.getTime() : typeof at === 'string' && /^\d{4}-\d\d-\d\dT/.test(at) ? Date.parse(at) : Number.NaN if (Number.isNaN(ms)) throw httpError(400, 'Zustimmungsnachweis: Zeitpunkt fehlt oder ist ungültig') return new Date(ms).toISOString() } function pruefeTexte(texte) { if (!Array.isArray(texte) || texte.length === 0) throw httpError(400, 'Zustimmungsnachweis: Texte fehlen') for (const eintrag of texte) { const ok = eintrag !== null && typeof eintrag === 'object' && !Array.isArray(eintrag) && typeof eintrag.id === 'string' && eintrag.id !== '' && typeof eintrag.text === 'string' && eintrag.text !== '' && (eintrag.haken === undefined || typeof eintrag.haken === 'boolean') if (!ok) throw httpError(400, 'Zustimmungsnachweis: Text ungültig') } } function pruefeFassungen(fassungen) { if (fassungen === null || typeof fassungen !== 'object' || Array.isArray(fassungen)) { throw httpError(400, 'Zustimmungsnachweis: Fassungen fehlen') } } const sha256 = (text) => createHash('sha256').update(text).digest('hex') /** SHA-256 (hex) ueber JSON.stringify der Texte in der gespeicherten Reihenfolge. */ export function texteHash(texte) { return sha256(JSON.stringify(texte)) } /** * Nachweis anfuegen. fassungen: Name => Fassung (z. B. zustimmung, leistungsbeschreibung, avv); texte: [{ id, text, haken }] * genau in Anzeigereihenfolge. Erlaubt leer: stripeSessionId, ip, userAgent (Nachweis der Aktivierung durch das Team) und * haken je Text. Wirft 400 (und schreibt nichts), wenn etwas fehlt, ungueltig oder zu lang ist. * @returns {Promise} das angelegte Dokument */ export async function speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at } = {}) { const daten = { customerId: kennung(customerId, MAX_ID, 'Kunde'), projectId: kennung(projectId, MAX_ID, 'Projekt'), featureKey: kennung(featureKey, MAX_FEATURE_KEY, 'Funktion'), stripeSessionId: kennung(stripeSessionId, MAX_SESSION, 'Sitzung', { optional: true }), acceptedAt: zeitpunkt(at), } pruefeFassungen(fassungen) pruefeTexte(texte) const fassungenJson = JSON.stringify(fassungen) const texteJson = JSON.stringify(texte) if (fassungenJson.length > MAX_FASSUNGEN) throw httpError(400, 'Zustimmungsnachweis: Fassungen zu lang') if (texteJson.length > MAX_TEXTE) throw httpError(400, 'Zustimmungsnachweis: Texte zu lang') return createDocument(config.collections.featureConsents, { ...daten, fassungen: fassungenJson, texte: texteJson, textHash: sha256(texteJson), ip: kurz(ip, MAX_IP), userAgent: kurz(userAgent, MAX_USER_AGENT), konto: kurz(konto, MAX_KONTO), }) } /** Neueste Zustimmung (nach acceptedAt) dieses Kunden zu dieser Funktion im Projekt, sonst null. */ export async function letzteZustimmung({ customerId, projectId, featureKey } = {}) { const vollstaendig = [customerId, projectId, featureKey].every((value) => typeof value === 'string' && value !== '') if (!vollstaendig) return null const [doc] = await listDocuments(config.collections.featureConsents, [ Query.equal('customerId', customerId), Query.equal('projectId', projectId), Query.equal('featureKey', featureKey), Query.orderDesc('acceptedAt'), Query.limit(1), ]) return doc || null } /** * Nachweis zu genau dieser Stripe-Sitzung, sonst null. Eine leere Sitzung fragt nicht ab: Nachweise ohne Sitzung (Aktivierung * durch das Team) tragen '' und wuerden sonst ueber idx_session getroffen. Gibt es mehrere Nachweise zur Sitzung (ein * wiederholtes Speichern), gilt der erste, denn der Klick, der die Sitzung ausgeloest hat, ist der Anker der Widerrufsfrist. * Lesefehler werden weitergereicht. */ export async function zustimmungFuerSitzung(stripeSessionId) { if (typeof stripeSessionId !== 'string' || !stripeSessionId.trim()) return null const docs = await listDocuments(config.collections.featureConsents, [Query.equal('stripeSessionId', stripeSessionId), Query.limit(SITZUNG_SEITE)]) if (!docs.length) return null return [...docs].sort((a, b) => String(a.acceptedAt).localeCompare(String(b.acceptedAt)))[0] } /** Dokument mit geparsten Feldern: fassungen als Objekt, texte als Array (das Dokument selbst bleibt unveraendert). */ export function leseZustimmung(doc) { return { ...doc, fassungen: JSON.parse(doc.fassungen), texte: JSON.parse(doc.texte) } } /** Fassungen, die der Kunde mit der Bestellung bestaetigt: die version jedes Registerfelds, das es gibt. */ export function zustimmungsfassungen(feature) { const fassungen = { zustimmung: feature.consent.version } for (const name of ['leistungsbeschreibung', 'avv', 'widerruf']) { if (feature[name]?.version) fassungen[name] = feature[name].version } return fassungen } /** * Texte, die der Kunde bei der Bestellung sieht, in Anzeigereihenfolge: alle consent.items, dann (falls uebergeben) der * gezeigte Belehrungstext samt Muster-Formular, zuletzt der Knopf. * @param {object} feature Registereintrag * @param {{ belehrung?: string | null }} [optionen] * @returns {{ id: string, text: string, haken: boolean }[]} */ export function nachweisTexte(feature, { belehrung = null } = {}) { const texte = feature.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })) if (belehrung !== null && belehrung !== undefined) { if (typeof belehrung !== 'string' || !belehrung) throw new Error('Nachweistexte: Belehrung muss ein nichtleerer Text sein') texte.push({ id: 'belehrung', text: belehrung, haken: false }) } texte.push({ id: 'knopf', text: feature.consent.knopf, haken: false }) return texte } // --------------------------------------------------------------------------------------------------------------- // Nachweis aus einer Anfrage (Checkout und Gratisweg "Einschalten"): Serverfassungen und -texte der Funktion, IP, Browser und Konto // aus der Anfrage. Hier statt in der Route, damit beide Wege denselben Nachweis schreiben. // --------------------------------------------------------------------------------------------------------------- /** Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde. */ export const actingPerson = (session) => session.adminName || session.adminEmail ? { name: session.adminName, email: session.adminEmail } : { name: session.name, email: session.email } /** "Name " der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */ const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '') /** * Zustimmungsnachweis aus einer Anfrage speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem Client), dazu * (falls der Haken `belehrung` der Funktion einen Text liefert) die Belehrung und die Zeit des Klicks. Jeder Fehler hier ist ein * Serverfehler: die Route antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext * steht nur im Log. Ohne sessionId (Gratisweg) traegt der Nachweis keine Stripe-Sitzung. * @param {object} req Express-Anfrage (ip, User-Agent, Sitzung) * @param {{ feature: object, haken?: object, customerId: string, projectId: string, sessionId?: string, klickAt: string }} p * feature: Registereintrag, beim Gratisweg freiweg(feature) (Zustimmung consentFrei) */ export async function speichereNachweisAusAnfrage(req, { feature, haken = {}, customerId, projectId, sessionId, klickAt }) { const belehrung = (await haken.belehrung?.()) ?? null const texte = nachweisTexte(feature, { belehrung }) try { await speichereZustimmung({ customerId, projectId, featureKey: feature.key, stripeSessionId: sessionId, fassungen: zustimmungsfassungen(feature), texte, ip: req.ip, userAgent: req.get('user-agent'), konto: kontoLabel(actingPerson(req.session)), at: klickAt, }) } catch (err) { throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err }) } }