markPaid holt Betreff und Details des Einrichtungs-Tickets aus dem Register (ticketTexte), createFeatureTicket bleibt die einzige Stelle, die das Ticket anlegt. Betreff auf 128, Details auf 7000 Zeichen gekuerzt (surrogatsicher, Schnitt an der Zeilengrenze, einheitliche Schlusszeile). Wirft die Details-Funktion oder liefert sie keinen Text, gelten die Notfall-Details (Kopfzeilen und Verweis ins WOMS). Nach einem 400 bei langen Details gibt es genau einen zweiten Versuch mit den Notfall-Details und derselben woid; 5xx, Netzfehler und kurze Details werden ohne Wiederholung weitergereicht. Stub termine/ticketDetails.js bis Task D4. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
222 lines
10 KiB
JavaScript
222 lines
10 KiB
JavaScript
import { config } from '../config.js'
|
||
import { termineFuerKunde } from './termine/schalter.js'
|
||
|
||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
|
||
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
|
||
// termine/schalter.js, das nur config liest): 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 })
|
||
}
|
||
|
||
/**
|
||
* Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der
|
||
* Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion().
|
||
*/
|
||
export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'
|
||
|
||
/**
|
||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), 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: async () => true,
|
||
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
||
},
|
||
}
|
||
|
||
/** 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)
|
||
}
|
||
|
||
/**
|
||
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }.
|
||
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
|
||
*/
|
||
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,
|
||
}
|
||
}
|