Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick

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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:05:12 +00:00
1 parent d8e4d79891
commit 786df1230e
5 files changed
+627 -4

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
// 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
}
+21 -1
View File
@@ -5,6 +5,7 @@ import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { featureConfig, featureKeys } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = featureKeys()
@@ -187,9 +188,26 @@ export async function backfillInvoiceLinks(customerId) {
}
}
/**
* Zeitpunkt des Vertragsschlusses einer bezahlten Funktion (Anker der Widerrufsfrist): Zeit des Klicks aus dem Nachweis
* zur bezahlten Stripe-Sitzung, sonst das Zustimmungsdatum am Dokument (Altbestand), sonst orderedAt. Eine spaetere,
* unbezahlte Zustimmung (andere Sitzung) zaehlt nie. Die Widerrufspruefung darf an der Nachweis-Collection nicht
* scheitern: ein Lesefehler faellt auf orderedAt zurueck (mit Log).
*/
async function vertragAt(doc) {
try {
const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId)
if (nachweis?.acceptedAt) return nachweis.acceptedAt
} catch (err) {
console.warn(`[funktionen] Zustimmungsnachweis zu Sitzung ${doc.stripeSessionId} nicht lesbar, Fristanker ist orderedAt:`, err.message)
return doc.orderedAt || ''
}
return doc.consentAt || doc.orderedAt || ''
}
/**
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht.
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. vertragAt ist der Anker der Widerrufsfrist.
*/
export async function paidFeatureOrders(customerId) {
const docs = await listDocuments(config.collections.portalFeatures, [
@@ -200,9 +218,11 @@ export async function paidFeatureOrders(customerId) {
const result = []
for (const doc of paid) {
result.push({
featureKey: doc.featureKey || '',
projekt: await projectName(doc.projectId).catch(() => ''),
state: doc.state,
orderedAt: doc.orderedAt || '',
vertragAt: await vertragAt(doc),
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
})
}