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:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user