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>
334 lines
16 KiB
JavaScript
334 lines
16 KiB
JavaScript
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,
|
||
}
|
||
}
|