Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+98 -13
View File
@@ -5,7 +5,7 @@ import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js'
import { aboLage, laeuft } from './aboStatus.js'
import { berlinDateTime } from './berlinTime.js'
import { loeschTag } from './termine/frist.js'
@@ -74,7 +74,8 @@ export async function getFeatureDoc(customerId, projectId, key) {
/**
* Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt).
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt; der Kunde sieht archivierte
* Projekte nicht mehr).
*/
export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) {
let project = null
@@ -84,7 +85,9 @@ export async function assertOwnProject(customerId, projectId, { forNewOrder = fa
if (err.status !== 404) throw err
}
if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt')
if (forNewOrder && project.archived === true) throw httpError(409, 'Dieses Projekt ist archiviert. Dafür kann keine Funktion neu eingeschaltet werden.')
if (forNewOrder && project.archived) {
throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.')
}
return project
}
@@ -174,14 +177,19 @@ export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
}
/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
const projects = await listDocuments(config.collections.websiteProjects, [
/**
* docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden.
* includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort
* eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen.
*/
export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) {
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId))
const projects = (await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id))
const result = []
for (const p of projects) {
const features = {}
@@ -280,7 +288,12 @@ export function hakenTexte(feature) {
.join('\n\n')
}
/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */
/**
* Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion.
* consent: { text, at } - Wortlaut (Kopie fuer das Team) und Zeit des Klicks. IP und Browser der Bestellung stehen NUR im Nachweis
* (featureConsents, Loeschfrist wie dort, Datenschutzerklaerung Abschnitt 7) und werden am Funktionsdokument nie gespeichert: die Felder
* consentIp und consentUserAgent werden leer geschrieben, das leert auch einen Wert aus einer frueheren Bestellung desselben Dokuments.
*/
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
assertKey(key)
const feature = featureConfig(key)
@@ -295,8 +308,8 @@ export async function markOrdered({ customerId, projectId, key, sessionId, conse
orderedAt: new Date().toISOString(),
consentText: String(consent.text || '').slice(0, 1000),
consentAt: consent.at || null,
consentIp: String(consent.ip || '').slice(0, 64),
consentUserAgent: String(consent.userAgent || '').slice(0, 300),
consentIp: '',
consentUserAgent: '',
})
}
@@ -512,7 +525,10 @@ function assertReason(reason) {
*/
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
assertReason(reason)
const doc = await withLocks([`feature-disable:${docId}`], async () => {
// Dieselbe Sperre wie die Freischaltung (activateClosedTickets, freischaltenNachAbnahme): beide schreiben den Zustand desselben Dokuments und
// duerfen sich nicht ueberholen. Kein Halter von feature-activate:<id> ruft disable() auf; die Reihenfolge feature-admin -> feature-activate
// besteht schon. Keine Mehrfach-Sperre (withLocks mit mehreren Schluesseln kann sich verklemmen).
const doc = await withLocks([`feature-activate:${docId}`], async () => {
const current = await getDocument(config.collections.portalFeatures, docId)
if (current.state === 'disabled') {
if (current.disabledAt) return current
@@ -527,9 +543,22 @@ export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
})
})
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
if (doc?.featureKey === 'shop' && doc.siteKey) await leereShopCache(doc.siteKey)
return doc
}
// Shop abgeschaltet (auch ueber disableCustomerFeatures): der Kontext-Cache der oeffentlichen Schnittstelle haelt den Zustand bis zu
// 30 Sekunden, solange wuerden noch Bestellungen angenommen. Best effort, der Zustand ist schon gespeichert.
// Dynamischer Import: shop/kontext.js importiert dieses Modul.
async function leereShopCache(siteKey) {
try {
const { leereCache } = await import('./shop/kontext.js')
leereCache(siteKey)
} catch (err) {
console.warn('[siteFeatures] Shop-Cache nicht geleert:', err.message)
}
}
/**
* Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete
* behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem
@@ -585,16 +614,65 @@ async function assertAboLaeuftFuerAktivierung(customerId) {
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
}
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
export const TEXTFORM_KEYS = ['termine', 'shop']
export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : config.termine.avvVersion)
/**
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key).
*/
export function textformNachweisText({ date, channel }, fassung, key = 'termine') {
const [jahr, monat, tag] = String(date).split('-')
if (key === 'shop') {
return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${fassung}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${fassung} abgibt.`
}
return `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${fassung}) am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`
}
// Erklaerungen der Webshop-Bestelltexte, die auch bei der Aktivierung in Textform gelten (ohne Widerrufsverlangen und Pflichtinformationen:
// die gehoeren zur kostenpflichtigen Online-Bestellung)
const SHOP_TEXTFORM_ERKLAERUNGEN = ['unterlagen', 'verantwortung', 'stripe']
/** Texte des Nachweises in Speicherreihenfolge: Termine = ein Satz; Webshop = Satz mit Datum und Weg, danach der Wortlaut der Erklaerungen der Fassung. */
function textformNachweisTexte(textform, fassung, key) {
const satz = { id: 'textform', text: textformNachweisText(textform, fassung, key) }
if (key !== 'shop') return [satz]
const items = featureConfig('shop').consent.items.filter((i) => SHOP_TEXTFORM_ERKLAERUNGEN.includes(i.id))
return [satz, ...items.map(({ id, text }) => ({ id, text }))]
}
/**
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
* News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen.
* textform (nur Terminplaner und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis
* (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung
* gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet).
*/
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) {
assertKey(key)
if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop')
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
if (textform) {
const fassung = textformFassung(key)
await speichereZustimmung({
customerId,
projectId,
featureKey: key,
stripeSessionId: '',
fassungen: { leistungsbeschreibung: fassung, avv: fassung },
texte: textformNachweisTexte(textform, fassung, key),
ip: '',
userAgent: '',
konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`,
at: now,
})
}
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
}
@@ -1188,6 +1266,13 @@ export async function unmatchedNotice(session) {
async function adoptPaidSession(session) {
const { customerId, projectId, featureKey } = session.metadata || {}
const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null
// Erneut zugestelltes Ereignis ("Resend event" in Stripe) zu einer Bestellung, die laengst eingerichtet, abgeschaltet und nach 30 Tagen
// geloescht wurde (off, purchaseStatus 'zurueckgesetzt', dieselbe Sitzung): sie ist ausgeliefert, nichts wird neu eingerichtet und
// niemand benachrichtigt (kein Ticket, keine K15, kein T16).
if (doc?.state === 'off' && doc.purchaseStatus === 'zurueckgesetzt' && doc.stripeSessionId === session.id) {
console.warn(`[funktionen] Sitzung ${session.id} gehoert zu einer Funktion, die nach dem Abo-Ende schon geloescht und zurueckgesetzt wurde - nicht uebernommen, nichts gesendet`)
return false
}
if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) {
console.error(
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`