server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung, zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash, zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register). paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung verschiebt den Anker nicht. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
161 lines
7.3 KiB
JavaScript
161 lines
7.3 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'
|
|
|
|
// 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<object>} 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
|
|
}
|