POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau, loeschungAm sowie zeitenStatus und zeitenLeer je Projekt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
9.7 KiB
JavaScript
205 lines
9.7 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// 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)
|
|
*/
|
|
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 })
|
|
}
|
|
}
|