Files
Webklar-Kundenbereich/server/services/featureRegistry.js
T
ClaudeandClaude Sonnet 5.5 bfe3895a50 Kundenansicht neu P7: Einschalten zeigt Linkzeile, Häkchen und Unterlagen der Fassung 6 aus P8
Verdrahtet P5 Task 15 und P7 Task 0 (N33): consentAbo liest Linkzeile und Hinweis aus WORTLAUT_V6, unterlagenFuer die fünf Funktionen aus FUNKTIONEN_UNTERLAGEN_V6 (Analyse bleibt bei den Alt-Unterlagen bis zur Freigabe), config.aboFunktionen.texte.hinweis entfällt, pdfAnhaenge liest PDFs der Fassung 6, sichereAdresse lässt /neu/rechtliches/ zu. Der Analyse-Kundenweg setzt wie der Team-Weg den Abo-Marker (unlockedBy und einrichtungAbrechnung abo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:56:40 +00:00

422 lines
21 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 { unterkunftFuerKunde } from './unterkunft/schalter.js'
import { zeitenFuerKunde } from './zeiten/schalter.js'
import { analyseUnterlagen, unterlagenFuer } from './aboUnterlagen.js'
import { WORTLAUT_V6, mitFassung } from './products.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, aboUnterlagen.js, ein Blattmodul,
// das nur config, analyseLegal.js und products.js importiert, und products.js, das nur config und den Schalter 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, 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 Buchungskalenders abrufbar? (legalDocs.featureLegalReady, fail-closed, wirft nie.) */
async function unterkunftUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('unterkunft')
}
/** 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,
},
// Buchungskalender: Anfrage-Funktion und Freischaltung sind bis Task 11 Stubs (unterkunft/anfrageFunktion.js, freischaltung.js)
unterkunft: {
verfuegbar: (email) => unterkunftFuerKunde(email),
bestellpruefung: (args) => import('./unterkunft/anfrageFunktion.js').then((m) => m.anfrageOffenePunkte(args)),
unterlagenBereit: unterkunftUnterlagenBereit,
belehrung: termineBelehrung,
freischaltbar: (doc) => import('./unterkunft/freischaltung.js').then((m) => m.freischaltbar(doc)),
},
// 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 }
}
// ---------------------------------------------------------------------------------------------------------------
// Abo-Modell der Funktionen (Kundenansicht neu, P5): Katalogfelder, Einschalt-Erklaerung ohne Preis und Unterlagen.
// Reine Lesefunktionen auf config.features und config.aboFunktionen; die Unterlagen kommen aus aboUnterlagen.js.
// ---------------------------------------------------------------------------------------------------------------
/** Schluessel des Katalogs in Anzeigereihenfolge (Kopie), mit 'analyse' am Ende. */
export function katalogReihenfolge() {
return [...config.aboFunktionen.reihenfolge]
}
/** { beschreibung, bild, ablauf, einrichtung, nurBetrieb } einer Funktion (auch 'analyse'); unbekannter Schluessel => null. */
export function katalogFelder(key) {
const quelle = key === 'analyse' ? config.aboFunktionen.analyse : featureConfig(key)
if (!quelle) return null
const { beschreibung, bild, ablauf, einrichtung, nurBetrieb } = quelle
return { beschreibung, bild, ablauf, einrichtung, nurBetrieb }
}
/**
* Einschalt-Erklaerung einer Funktion im Abo-Modell: { version, knopf: 'Einschalten', items } in Anzeigereihenfolge
* einschalten-zeile (Linkzeile mit Fassung, kein Haken), hinweis (nur shop, unterkunft, termine; kein Haken) und beginn
* (Haken, nur fuer Privatkunden innerhalb der Widerrufsfrist). Baut die Items bei jedem Aufruf: Linkzeile (mit AVV oder ohne) und
* Hinweis aus WORTLAUT_V6 (Fassung 6, P8), beginn und Fassung aus config.aboFunktionen (Literale, Gleichheit gepinnt in
* test/aboWortlautV6.test.js und test/knVerdrahtung.test.js). Analyse und unbekannte Schluessel => null.
*/
export function consentAbo(key) {
if (!featureConfig(key)) return null
const { avv } = unterlagenFuer(key)
const { fassung: version, texte } = config.aboFunktionen
const zeile = mitFassung(avv ? WORTLAUT_V6.linkzeile.einschaltenMitAvv : WORTLAUT_V6.linkzeile.einschaltenOhneAvv, version)
const items = [{ id: 'einschalten-zeile', text: zeile, haken: false }]
if (Object.hasOwn(WORTLAUT_V6.hinweis, key)) items.push({ id: 'hinweis', text: WORTLAUT_V6.hinweis[key], haken: false })
items.push({ id: 'beginn', text: texte.beginn, haken: true, nurPrivatInFrist: true })
return { version, knopf: 'Einschalten', items }
}
/** Die Erklaerung, wie sie angezeigt wird: ohne beginn, solange das Haekchen nicht verlangt ist. */
export function consentAboAktiv(key, { beginn = false } = {}) {
const consent = consentAbo(key)
if (!consent) return null
return { ...consent, items: consent.items.filter((item) => item.id !== 'beginn' || beginn) }
}
/** Abo-Weg einer Funktion fuer den Nachweis: derselbe Eintrag mit der Abo-Erklaerung, den Unterlagen aus aboUnterlagen.js und ohne Widerrufsbelehrung. */
export function aboweg(feature, { beginn = false } = {}) {
const { leistungsbeschreibung, avv } = unterlagenFuer(feature.key)
return { ...feature, consent: consentAboAktiv(feature.key, { beginn }), leistungsbeschreibung, avv, widerruf: null }
}
/** Einschalt-Erklaerung der Analyse: Linkzeile (Fassung der AVV), Zielgruppen-Haken (Kinder unter 16) und Knopf. */
export function consentAboAnalyse() {
const { avvVersion } = analyseUnterlagen()
const { fassung: version, texte } = config.aboFunktionen
return {
version,
avvVersion,
knopf: texte.analyseKnopf,
items: [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: texte.zielgruppe, haken: true },
],
}
}
/** Abo-Weg der Analyse in der Form, die speichereNachweisAusAnfrage erwartet. */
export function abowegAnalyse() {
const { avvVersion } = analyseUnterlagen()
return { key: 'analyse', consent: consentAboAnalyse(), leistungsbeschreibung: { version: avvVersion }, avv: { version: avvVersion }, widerruf: null }
}
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,
}
}