Files
Webklar-Kundenbereich/server/services/featureRegistry.js
T
ClaudeandClaude Opus 5.5 e87140e5ba 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>
2026-10-06 06:12:23 +00:00

334 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import { termineFuerKunde } from './termine/schalter.js'
import { shopFuerKunde } from './shop/schalter.js'
import { zeitenFuerKunde } from './zeiten/schalter.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen:
// termine/schalter.js, shop/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
// innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen, dazu nachFreischaltung. */
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung']
/**
* Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus
* termineConfig; ohne Dokument beides null) nach termine/fragebogen.js. Ohne abo und mit mitAbo: false: den Abo-Punkt (h)
* wendet der Checkout nicht an, die Abo-Stufe (7) der Route liefert kein_abo | gekuendigt | nicht_aktiv.
*/
async function termineBestellpruefung({ customerId, projectId, feature }) {
const [{ getFeatureDoc }, { ladeEntwurf }, { bestellpruefung }] = await Promise.all([
import('./siteFeatures.js'),
import('./featureOrder.js'),
import('./termine/fragebogen.js'),
])
const doc = await getFeatureDoc(customerId, projectId, 'termine')
const { einstellung, fragebogen } = doc ? await ladeEntwurf(doc.$id) : { einstellung: null, fragebogen: null }
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
}
/**
* Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung
* ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet
* freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed:
* ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt
* das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird
* hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher.
*/
async function termineFreischaltbar(doc) {
const { freischaltHindernis } = await import('./termine/freischaltung.js')
return (await freischaltHindernis(doc)) === null
}
/**
* Freischaltpruefung der Oeffnungszeiten (Spec 4.4): die Einrichtung ist erst freizugeben, wenn der Kunde Zeiten eingetragen hat, also
* ein Hauptplan gespeichert ist und mindestens ein Wochentag nicht "zu" ist (modusUndFenster: auch ein unlesbarer Eintrag zaehlt als zu).
* Fail-closed: jeder Fehler (Lesefehler, kaputte Daten) ergibt false und bleibt im Log (ohne Kundendaten), das Dokument bleibt in
* 'setup' und der naechste Lauf des 5-Minuten-Jobs fragt erneut. Der Aufrufer uebergibt das unter der Sperre frisch gelesene Dokument.
*/
async function zeitenFreischaltbar(doc) {
try {
const [{ ladeZeiten }, { hauptplan, modusUndFenster }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/zeitlogik.js')])
const haupt = hauptplan((await ladeZeiten(doc.$id))?.daten ?? null)
if (!haupt) return false
return [1, 2, 3, 4, 5, 6, 7].some((tag) => modusUndFenster(haupt.tage?.[tag]).modus !== 'zu')
} catch (err) {
console.error(`[funktionen] Öffnungszeiten ${doc?.$id}: Zeiten für die Freischaltung nicht prüfbar:`, err.message)
return false
}
}
/**
* Nach der Freischaltung der Oeffnungszeiten (Spec 4.4): den Hash des Hauptplan-Wochenplans als ersatzHash merken. Das Ersatz-HTML,
* das das Team beim Einbau setzt, zeigt diesen Wochenplan; weicht er beim Abschalten ab, entsteht das Ticket "Ersatz-Tabelle
* aktualisieren". setzeErsatzHash nimmt die Sperre zeiten:<projekt> selbst (hier nicht halten) und fasst Daten und Versionen nicht an.
* Wirft bei Lese- und Schreibfehlern; der Aufrufer (runActivation) loggt und laesst Freischaltung und K16 davon unberuehrt.
*/
async function zeitenNachFreischaltung(doc) {
const [{ ladeZeiten, setzeErsatzHash }, { wochenplanHash }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/ersatz.js')])
await setzeErsatzHash(doc, wochenplanHash((await ladeZeiten(doc.$id))?.daten ?? null))
}
/**
* Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der
* aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst
* beim Aufruf.
*/
async function termineUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('termine')
}
/**
* Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf
* zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen
* wird sie erst beim Aufruf (kein Importzyklus). Sie gilt fuer jede kostenpflichtige Funktion; auch die Oeffnungszeiten live
* (Haken belehrung von zeiten) liefern sie.
*/
async function termineBelehrung() {
const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js')
return widerrufsbelehrungFunktion()
}
/**
* Bestellpruefung des Webshops im Checkout: die Anfrage des Projekts muss vom Team angenommen sein (shop/anfrage.js,
* Block B). Geladen werden die Dienste erst beim Aufruf.
*/
async function shopBestellpruefung({ customerId, projectId }) {
const { anfrageOffenePunkte } = await import('./shop/anfrage.js')
return anfrageOffenePunkte({ customerId, projectId })
}
/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */
async function shopUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('shop')
}
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
*/
const STANDARD_HAKEN = {
news: {},
termine: {
verfuegbar: (email) => termineFuerKunde(email),
bestellpruefung: termineBestellpruefung,
unterlagenBereit: termineUnterlagenBereit,
belehrung: termineBelehrung,
freischaltbar: termineFreischaltbar,
},
// Webshop: kein freischaltbar (ein geschlossenes Ticket genuegt); Belehrung aus derselben Quelle wie beim Terminplaner
shop: {
verfuegbar: (email) => shopFuerKunde(email),
bestellpruefung: shopBestellpruefung,
unterlagenBereit: shopUnterlagenBereit,
belehrung: termineBelehrung,
},
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung wie beim Terminplaner,
// unterlagenBereit immer (die Oeffnungszeiten haben nur die Leistungsbeschreibung als Seite, keine PDFs); freischaltbar: erst mit
// eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken
zeiten: {
verfuegbar: (email) => zeitenFuerKunde(email),
unterlagenBereit: async () => true,
belehrung: termineBelehrung,
freischaltbar: zeitenFreischaltbar,
nachFreischaltung: zeitenNachFreischaltung,
},
}
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
export function featureConfig(key) {
if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) return null
return config.features[key]
}
/** Schluessel der Funktionen im Register, in Registerreihenfolge. */
export function featureKeys() {
return Object.keys(config.features)
}
/**
* Schluessel der Funktionen, die nach einem geschlossenen Einrichtungs-Ticket freigeschaltet werden (ticketFreischaltung: true,
* nur genau true), in Registerreihenfolge. config.features wird bei jedem Aufruf gelesen.
*/
export function ticketFreischaltungKeys() {
return featureKeys().filter((key) => config.features[key].ticketFreischaltung === true)
}
/**
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar?, nachFreischaltung? }.
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
* freischaltbar(doc) => Promise<boolean>: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden?
* Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets).
* nachFreischaltung(doc) => Promise<void>: Nacharbeit nach der Freischaltung (doc ist das aktive Dokument); runActivation ruft sie
* vor der K16 und faengt jeden Fehler (Log), Freischaltung und K16 haengen nie davon ab.
*/
export function featureHaken(key) {
const eintrag = featureConfig(key)
if (!eintrag) return {}
const standard = Object.hasOwn(STANDARD_HAKEN, key) ? STANDARD_HAKEN[key] : {}
const haken = {}
for (const name of HAKEN_NAMEN) {
const fn = typeof eintrag[name] === 'function' ? eintrag[name] : standard[name]
if (typeof fn === 'function') haken[name] = fn
}
return haken
}
const NICHT_VERFUEGBAR = 'Diese Funktion ist noch nicht verfügbar.'
/**
* Pruefstufe 5 des Checkouts: Text, warum die Funktion diesem Kunden (noch) nicht angeboten wird, sonst null.
* Gefragt wird der Haken verfuegbar(email) (Terminplaner: Schalter TERMINE_ENABLED und Pilotliste); Funktionen ohne
* Haken (News) sind immer verfuegbar. Ein unbekannter Schluessel ist nicht verfuegbar (fail-closed).
*/
export function featureSperre(key, email) {
const eintrag = featureConfig(key)
if (!eintrag) return NICHT_VERFUEGBAR
const { verfuegbar } = featureHaken(key)
if (typeof verfuegbar !== 'function' || verfuegbar(email)) return null
return eintrag.unavailableMessage || NICHT_VERFUEGBAR
}
/**
* Gratisweg einer Funktion mit aboInklusive (Wartungs-Abo laeuft): derselbe Eintrag, nur mit der Zustimmung consentFrei
* (ein Haken, Knopf "Einschalten") statt consent. Eine Kopie, das Register bleibt unveraendert.
*/
export function freiweg(feature) {
return { ...feature, consent: feature.consentFrei }
}
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
/**
* Sicht fuer den Browser (Bestellseite): JSON-sicher, ohne Funktionen und ohne ticket/mails, eine Kopie
* (Aenderungen daran aendern das Register nicht). Unbekannter Schluessel => null.
*/
export function oeffentlicheAnsicht(key) {
const e = featureConfig(key)
if (!e) return null
return {
key: e.key,
name: e.label.name,
kurz: e.label.kurz,
priceLabel: e.priceLabel,
setupEstimate: e.setupEstimate,
consent: {
version: e.consent.version,
knopf: e.consent.knopf,
items: e.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
},
leistungsbeschreibung: fassung(e.leistungsbeschreibung),
avv: fassung(e.avv),
widerruf: fassung(e.widerruf),
returnHash: e.returnHash,
requiresWartungsAbo: e.requiresWartungsAbo === true,
}
}
// ---------------------------------------------------------------------------------------------------------------
// Einrichtungs-Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel und Notfall-Details.
// createFeatureTicket (ticketAdmin.js) ist die einzige Stelle, die das Ticket anlegt; markPaid holt die Texte hier.
// ---------------------------------------------------------------------------------------------------------------
/** Laengendeckel der Ticket-Felder (workorders.topic / workorders.details). */
export const TICKET_TOPIC_MAX = 128
export const TICKET_DETAILS_MAX = 7000
/** Ab dieser Laenge gelten Details als "lang": nur dann wiederholt createFeatureTicket nach einem 400 mit den Notfall-Details. */
export const LANGE_DETAILS = 1000
/** Einheitliche Schlusszeile eines gekuerzten Ticket-Textes. */
export const TICKET_GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
const istHighSurrogat = (code) => code >= 0xd800 && code <= 0xdbff
/**
* Text auf hoechstens max Zeichen (UTF-16-Einheiten, wie string.length) kuerzen, surrogatsicher: nie ein halbes Zeichenpaar.
* Passt der Text, bleibt er unveraendert (ohne endzeile). Sonst wird so viel Text behalten, dass mit der Endzeile genau max
* nicht ueberschritten wird: anZeile schneidet an der letzten Zeilengrenze (ohne Zeilengrenze im Platz: harter Schnitt) und
* setzt die Endzeile in eine eigene Zeile; ohne anZeile haengt die Endzeile direkt an den Schnitt.
*/
export function kuerze(text, max, { anZeile = false, endzeile = '' } = {}) {
const s = typeof text === 'string' ? text : String(text ?? '')
if (s.length <= max) return s
const trenner = anZeile && endzeile ? '\n' : ''
const platz = max - endzeile.length - trenner.length
const hartSchnitt = (n) => {
let teil = s.slice(0, Math.max(0, n))
if (teil && istHighSurrogat(teil.charCodeAt(teil.length - 1))) teil = teil.slice(0, -1)
return teil
}
if (platz <= 0) return hartSchnitt(max)
let teil
if (anZeile) {
const grenze = s.lastIndexOf('\n', platz)
teil = grenze > 0 ? s.slice(0, grenze) : hartSchnitt(platz)
} else {
teil = hartSchnitt(platz)
}
teil = teil.trimEnd()
return teil ? `${teil}${trenner}${endzeile}` : endzeile
}
/** Link ins WOMS-Kundenpanel "Funktionen" mit geoeffneter Einbauanleitung des Projekts (Ticket, T16, Anleitungs-Route). */
export function anleitungsLink(customerId, projectId) {
return `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId ?? '')}?tab=funktionen&anleitung=${encodeURIComponent(projectId ?? '')}`
}
const projektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Notfall-Details des Tickets: Kopfzeilen (Funktion, Projekt, Domain, Kunde, siteKey) und ein Verweis auf die Anleitung im
* WOMS. Ohne Rechenarbeit und ohne Dienste, damit es auch dann klappt, wenn die Anleitung des Registers nicht entsteht.
* ctx = { feature, doc, project, customer } (customer best effort oder null).
*/
export function minimaleTicketDetails(ctx) {
const { feature, doc, project, customer } = ctx || {}
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || doc?.featureKey || ''}`,
`Projekt: ${projektName(project)}`,
`Domain: ${domain}`,
`Kunde: ${customer?.name || doc?.customerId || ''}`,
`siteKey: ${doc?.siteKey || ''}`,
`Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`,
].join('\n')
}
/**
* Texte des Einrichtungs-Tickets einer Funktion aus dem Register: { topic, details, fallbackDetails }.
* topic auf TICKET_TOPIC_MAX, details auf TICKET_DETAILS_MAX gekuerzt (Schnitt an der letzten Zeilengrenze, eine
* einheitliche Schlusszeile). Wirft feature.ticket.details oder liefert es Leeres/Nicht-Text, gilt fallbackDetails
* (minimaleTicketDetails): bezahlt ist bezahlt, das Ticket entsteht immer.
*/
export async function ticketTexte(ctx) {
const { feature, project } = ctx || {}
const kuerzeDetails = (text) => kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT })
const fallbackDetails = kuerzeDetails(minimaleTicketDetails(ctx))
let topic = ''
try {
topic = feature.ticket.topic(project)
} catch (err) {
console.error(`[funktionen] Ticket-Betreff (${feature?.key}) nicht berechenbar:`, err.message)
}
if (typeof topic !== 'string' || !topic.trim()) topic = `${feature?.label?.name || 'Funktion'} einbauen – ${projektName(project)}`.trim()
let details = ''
try {
details = await feature.ticket.details(ctx)
} catch (err) {
console.error(`[funktionen] Ticket-Details (${feature?.key}) nicht berechenbar, Notfall-Details:`, err.message)
}
if (typeof details !== 'string' || !details.trim()) details = fallbackDetails
return {
topic: kuerze(topic.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }),
details: kuerzeDetails(details),
fallbackDetails,
}
}