Files
Webklar-Kundenbereich/server/services/featureConsents.js
T
2026-10-16 15:56:00 +00:00

212 lines
10 KiB
JavaScript

// 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'
import { KUNDENARTEN } from './kundenart.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.
* kundenart (optional, 'unternehmen' | 'privat'): Kundenart zum Klickzeitpunkt (V4); das Feld wird nur geschrieben, wenn es
* uebergeben wurde (ohne Setup-Skript kennt Appwrite es nicht), jeder andere uebergebene Wert ist ein 400.
* @returns {Promise<object>} das angelegte Dokument
*/
export async function speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at, kundenart } = {}) {
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')
if (kundenart !== undefined && !KUNDENARTEN.includes(kundenart)) throw httpError(400, 'Zustimmungsnachweis: Kundenart ungültig')
return createDocument(config.collections.featureConsents, {
...daten,
...(kundenart !== undefined ? { kundenart } : {}),
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 <E-Mail>" 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), im Abo-Modell aboweg(feature)
* kundenart (optional): Kundenart zum Klickzeitpunkt, wird nur durchgereicht, wenn sie uebergeben wurde
*/
export async function speichereNachweisAusAnfrage(req, { feature, haken = {}, customerId, projectId, sessionId, klickAt, kundenart }) {
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,
...(kundenart !== undefined ? { kundenart } : {}),
})
} catch (err) {
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
}
}