import { config } from '../config.js' import { termineFuerKunde } from './termine/schalter.js' import { shopFuerKunde } from './shop/schalter.js' // Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop; Daten in server/config.js). // Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen: // termine/schalter.js und shop/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. */ const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar'] /** * 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 } /** * 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). */ 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, }, } /** 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? }. * Wer config.features.. als Funktion setzt (Tests), ueberschreibt den Standard. * freischaltbar(doc) => Promise: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden? * Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets). */ 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 } 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, } }