diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js
index ab8dc16..508866b 100644
--- a/server/services/siteFeatures.js
+++ b/server/services/siteFeatures.js
@@ -4,7 +4,7 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
-import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
+import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { aboLage, laeuft } from './aboStatus.js'
import { loeschTag } from './termine/frist.js'
@@ -711,7 +711,7 @@ async function ticketWoidOf(doc) {
/**
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
- * Block gesundheit bleibt hier aus (D4 fuellt ihn).
+ * Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice).
*/
function sendT16({ funktion, ...werte }, bloecke) {
return sendAdminNotice({
@@ -733,11 +733,48 @@ function sendT16({ funktion, ...werte }, bloecke) {
})
}
+/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */
+const GESUNDHEIT_TEXT_MAX = 500
+
+/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */
+function gesundheitText(meldungen) {
+ const alle = meldungen.join('; ')
+ if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle
+ let text = ''
+ let n = 0
+ for (const meldung of meldungen) {
+ const naechster = text ? `${text}; ${meldung}` : meldung
+ if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break
+ text = naechster
+ n += 1
+ }
+ return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' })
+}
+
+/**
+ * T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs
+ * (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei
+ * jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil
+ * termine/kontext.js dieses Modul importiert.
+ */
+async function gesundheitHinweis(doc) {
+ if (doc.featureKey !== 'termine') return ''
+ try {
+ const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')])
+ const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung)
+ return meldungen.length ? gesundheitText(meldungen) : ''
+ } catch (err) {
+ console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message)
+ return ''
+ }
+}
+
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
const mails = featureConfig(doc.featureKey)?.mails
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
const anleitung = Boolean(mails?.anleitung) && !failed
+ const gesundheit = failed ? '' : await gesundheitHinweis(doc)
await sendT16(
{
kunde_name: customer?.name || customer?.email || doc.customerId,
@@ -747,8 +784,9 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
+ gesundheit_text: gesundheit,
},
- { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
+ { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '' }
)
}
diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js
index b8ec150..80966a3 100644
--- a/server/services/termine/einbauanleitung.js
+++ b/server/services/termine/einbauanleitung.js
@@ -1,5 +1,5 @@
import { config } from '../../config.js'
-import { anleitungsLink } from '../featureRegistry.js'
+import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js'
import { gesundheitsWarnungen } from './fragebogen.js'
import {
VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
@@ -133,7 +133,7 @@ export function cssVorschlag(farbe) {
// ================================================================ Generator einbauanleitung()
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
-// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4 haengt die Kurzfassung (`kurz`) an.
+// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung.
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
@@ -555,17 +555,188 @@ const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder
*/
const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
-/** Warnungen fuer das Team in fester Reihenfolge: Banner, Pflichtangaben, Impressum, Datenschutz-URL, Gesundheit, Kontrast, Datenschutz-Absatz. */
-function warnungenAus(d) {
- const warnungen = []
- if (d.entwurf) warnungen.push(ENTWURF_BANNER)
- for (const feld of fehlendePflichtangaben(d.e)) warnungen.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
- if (!zeile(d.betrieb.impressumUrl)) warnungen.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
- if (!zeile(d.betrieb.datenschutzUrl)) warnungen.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
- for (const meldung of gesundheitsWarnungen(d.e)) warnungen.push(meldungText(meldung))
- warnungen.push(...cssVorschlag(d.farbe).hinweise)
- if (!d.datenschutz) warnungen.push(WARNUNG_OHNE_DATENSCHUTZ)
- return warnungen
+/**
+ * Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen:
+ * wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer.
+ */
+export function gesundheitsMeldungen(einstellung) {
+ return gesundheitsWarnungen(einstellung).map(meldungText)
+}
+
+/**
+ * Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits-
+ * meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit
+ * in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen.
+ */
+function warnungsTeile(d) {
+ const davor = []
+ if (d.entwurf) davor.push(ENTWURF_BANNER)
+ for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
+ if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
+ if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
+ const danach = [...cssVorschlag(d.farbe).hinweise]
+ if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ)
+ return { davor, gesundheit: gesundheitsMeldungen(d.e), danach }
+}
+
+// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4)
+
+// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz
+// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die
+// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet,
+// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen
+// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge-
+// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets.
+
+const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere"
+const KURZ_NAME_MAX = 60
+const KURZ_KOPF_MAX = 120
+const KURZ_WARNUNGEN = 5
+const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:'
+const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'
+
+const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' })
+const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' })
+
+/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */
+function standZeile(version, geaendert, hatEinstellung) {
+ const n = Number.isInteger(version) && version >= 1 ? version : 0
+ if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'
+ if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)`
+ return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}`
+}
+
+/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */
+function kurzKopf(k) {
+ const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain)
+ return [
+ 'Funktion: Terminplaner',
+ `Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`,
+ `Domain: ${kopfWert(domain)}`,
+ `Kunde: ${kopfWert(k.kunde)}`,
+ `siteKey: ${k.siteKey}`,
+ standZeile(k.version, k.geaendert, k.hatEinstellung),
+ ].join('\n')
+}
+
+/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */
+function kurzWunschZeilen(bezeichnung, wert) {
+ const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '')
+ return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)]
+}
+
+/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */
+function kurzWunsch(w) {
+ const zeilen = [
+ ...kurzWunschZeilen('Knopf-Text', w.knopfText),
+ ...kurzWunschZeilen('Stelle', w.stelle),
+ ...kurzWunschZeilen('Gestaltung', w.gestaltung),
+ ...kurzWunschZeilen('Bemerkung', w.bemerkung),
+ ]
+ return [
+ 'Kundenwunsch (Text des Kunden):',
+ ` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`,
+ ...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']),
+ ].join('\n')
+}
+
+/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */
+function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) {
+ const n = eintraege.length
+ const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : ''
+ const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ')
+ return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}`
+}
+
+function kurzUmfang(k) {
+ return [
+ 'Umfang:',
+ kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }),
+ kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }),
+ kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'),
+ ].join('\n')
+}
+
+/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */
+function kurzSchritte(k) {
+ const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form'
+ const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : ''
+ return [
+ 'Schritte:',
+ '1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.',
+ '2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.',
+ '3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.',
+ `4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_ZWISCHENREGEL}).`,
+ '5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).',
+ `6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`,
+ ].join('\n')
+}
+
+/**
+ * Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen,
+ * ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink.
+ */
+function kurzfassung(k) {
+ const pflicht = {
+ kopf: kurzKopf(k),
+ gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '',
+ snippet: `Einbau-Code (Snippet):\n${k.snippet}`,
+ schritte: kurzSchritte(k),
+ verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK,
+ }
+ const umfang = k.hatEinstellung ? kurzUmfang(k) : ''
+ const wunsch = kurzWunsch(k.wunsch)
+ const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`)
+ let mitUmfang = Boolean(umfang)
+ let mitWunsch = true
+
+ const zusammen = () =>
+ [
+ pflicht.kopf,
+ mitWunsch ? wunsch : '',
+ mitUmfang ? umfang : '',
+ warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '',
+ pflicht.gesundheit,
+ pflicht.snippet,
+ pflicht.schritte,
+ pflicht.verweis,
+ ].filter(Boolean).join('\n\n').toWellFormed()
+
+ let text = zusammen()
+ if (text.length > TICKET_DETAILS_MAX) {
+ mitUmfang = false
+ text = zusammen()
+ }
+ while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) {
+ warnungen.pop()
+ text = zusammen()
+ }
+ if (text.length > TICKET_DETAILS_MAX) {
+ mitWunsch = false
+ text = zusammen()
+ }
+ return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text
+}
+
+const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m
+
+/**
+ * Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des
+ * Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand.
+ */
+export function ticketStand(details) {
+ if (typeof details !== 'string') return null
+ const treffer = STAND_RE.exec(details)
+ return treffer ? Number(treffer[1]) : null
+}
+
+/**
+ * Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version.
+ * Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket).
+ */
+export function driftWarnung(ticketVersion, jetzt) {
+ if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null
+ return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
}
// ---------------------------------------------------------------- Zusammenbau
@@ -603,27 +774,45 @@ function projektText(project) {
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
* abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
* text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
- * Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert". `kurz` ist '' (kommt mit D4).
+ * Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert".
+ * kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile,
+ * ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis).
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
const banner = entwurf ? [ENTWURF_BANNER] : []
+ const geaendertAm = updatedAt ? new Date(updatedAt) : null
const gemeinsam = {
feature,
+ project,
siteKey: zeile(feature?.siteKey),
portal: zeile(config.publicBaseUrl),
wunsch: kundenwunsch(fragebogen),
anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
+ version,
+ geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '',
}
if (!istObjekt(einstellung)) {
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
- return { stand, warnungen: [...banner, 'Keine Einstellungen gespeichert'], abschnitte, text: volltext(abschnitte), kurz: '' }
+ const warnungen = [...banner, 'Keine Einstellungen gespeichert']
+ const kurz = kurzfassung({
+ ...gemeinsam,
+ kunde: zeile(kunde?.name),
+ hatEinstellung: false,
+ anrede: null,
+ leistungen: [],
+ ressourcen: [],
+ zusatzfelder: [],
+ warnungen,
+ gesundheit: [],
+ snippet: abschnitte[0].text,
+ })
+ return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz }
}
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
- const geaendert = updatedAt ? new Date(updatedAt) : null
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const daten = {
...gemeinsam,
@@ -631,8 +820,6 @@ export function einbauanleitung({ feature, project, einstellung, fragebogen, ver
now,
entwurf: Boolean(entwurf),
heuteTag: heute(now),
- version,
- geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
betrieb,
telefon: zeile(betrieb.telefon),
kunde: zeile(kunde?.name),
@@ -647,11 +834,21 @@ export function einbauanleitung({ feature, project, einstellung, fragebogen, ver
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
// der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
+ const teile = warnungsTeile(daten)
+ const kurz = kurzfassung({
+ ...daten,
+ hatEinstellung: true,
+ anrede: einstellung.anrede,
+ zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt),
+ warnungen: [...teile.davor, ...teile.danach],
+ gesundheit: teile.gesundheit,
+ snippet: abschnitte.find((a) => a.id === 'snippet').text,
+ })
return {
stand,
- warnungen: warnungenAus(daten),
+ warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach],
abschnitte,
text: volltext(abschnitte.filter((a) => a.id !== andere)),
- kurz: '',
+ kurz,
}
}
diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js
index 335e094..7b1c37c 100644
--- a/server/services/termine/kontext.js
+++ b/server/services/termine/kontext.js
@@ -106,6 +106,44 @@ export async function ladeEinstellung(featureId) {
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
}
+/**
+ * Der Fragebogen aus einem termineConfig-Dokument, so wie er gespeichert ist (geparstes Objekt) oder null. Anders als fragebogenAus kein
+ * leerer Fragebogen als Ersatz: fehlt das Attribut, ist es leer oder kein JSON-Objekt, ist das Ergebnis null (kaputtes JSON mit einer Logzeile).
+ */
+function fragebogenRoh(doc) {
+ const roh = doc.fragebogen
+ if (roh === undefined || roh === null || roh === '') return null
+ try {
+ const fragebogen = JSON.parse(roh)
+ if (!istObjekt(fragebogen)) throw new Error('kein Objekt')
+ return fragebogen
+ } catch (err) {
+ console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
+ return null
+ }
+}
+
+/**
+ * Der gespeicherte Stand einer Funktion fuer das Einrichtungs-Ticket und die Anleitung (TP2 D4): Einstellung, Version, Zeitpunkt der letzten
+ * Aenderung und Fragebogen, ohne Modus- und Zustandspruefung. Anders als ladeEinstellung unterscheidet er "noch nie gespeichert" von "unlesbar":
+ * null nur ohne termineConfig-Dokument; unlesbares JSON macht nur das jeweilige Feld null (die Version des Dokuments bleibt). Fehlt Collection
+ * oder Datenbank, wird der Fehler weitergereicht.
+ * @param {string} featureId ID des portalFeatures-Dokuments
+ * @returns {Promise<{ einstellung: object|null, version: number, updatedAt: string|null, fragebogen: object|null }|null>}
+ */
+export async function ladeStand(featureId) {
+ pruefeText(featureId, 'featureId')
+ const doc = await holeOderNull(config.collections.termineConfig, featureId)
+ if (!doc) return null
+ const { einstellung, version } = liesDokument(doc)
+ return {
+ einstellung,
+ version,
+ updatedAt: typeof doc.updatedAt === 'string' && doc.updatedAt !== '' ? doc.updatedAt : null,
+ fragebogen: fragebogenRoh(doc),
+ }
+}
+
async function baueKontext(feature) {
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
diff --git a/server/services/termine/ticketDetails.js b/server/services/termine/ticketDetails.js
index bef1032..39082fb 100644
--- a/server/services/termine/ticketDetails.js
+++ b/server/services/termine/ticketDetails.js
@@ -1,10 +1,48 @@
-// STUB (Block B) – ersetzt Task D4
-import { minimaleTicketDetails } from '../featureRegistry.js'
+import { TICKET_DETAILS_MAX, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
+import { einbauanleitung } from './einbauanleitung.js'
+import { ladeStand } from './kontext.js'
/**
- * Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details).
- * ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben.
+ * Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details, TP2 Task D4).
+ * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt: feature = Registereintrag (der Generator braucht ihn nicht),
+ * doc = portalFeatures-Dokument (siteKey, $id, customerId, projectId, unlockedByPurchase kommen von dort), project = websiteProjects-Dokument,
+ * customer = Kunde oder null (best effort).
+ *
+ * Inhalt: einbauanleitung(...).kurz, gebaut aus dem gespeicherten Stand der Einstellungen (ladeStand). Die Funktion wirft nie: bezahlt ist bezahlt,
+ * das Ticket entsteht immer. Scheitert das Lesen oder der Generator, stehen die Kopfzeilen, der Hinweis, dass die Anleitung nicht erzeugt werden
+ * konnte, und der Verweis auf das WOMS im Ticket (notfallDetails). Das Log nennt keine Daten des Betriebs.
*/
export async function termineTicketDetails(ctx) {
- return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
+ try {
+ const { doc, project, customer } = ctx
+ const stand = await ladeStand(doc.$id)
+ const { kurz } = einbauanleitung({
+ feature: doc,
+ project,
+ einstellung: stand?.einstellung ?? null,
+ fragebogen: stand?.fragebogen ?? null,
+ version: stand?.version ?? 0,
+ updatedAt: stand?.updatedAt ?? null,
+ kunde: { name: customer?.name || doc.customerId || '' },
+ now: new Date(),
+ })
+ if (typeof kurz !== 'string' || kurz.trim() === '') throw new Error('Kurzfassung leer')
+ return kurz
+ } catch (err) {
+ console.error(`[termine-ticket] Kurzfassung der Einbauanleitung nicht erzeugbar, Notfall-Details: ${err?.message}`)
+ return notfallDetails(ctx)
+ }
+}
+
+const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).'
+const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.'
+
+/** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */
+function notfallDetails(ctx) {
+ try {
+ const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } })
+ return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX)
+ } catch {
+ return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}`
+ }
}
diff --git a/test/featureMails.test.js b/test/featureMails.test.js
index 1e5f199..5f194de 100644
--- a/test/featureMails.test.js
+++ b/test/featureMails.test.js
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
// B9b ergaenzt T16 (Team-Hinweis je Funktion) und T19 (Erstattung faellig) in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
-const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
+const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
@@ -365,7 +365,7 @@ test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () =>
assert.equal(link, reg.anleitungsLink('c1', projectId))
assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html)
- // die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit füllt D4)
+ // die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit nur bei Treffern der Stichwortprüfung: T16 Gesundheit unten)
for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
}
@@ -428,6 +428,116 @@ test('T16 gesundheit: Block aus ohne Warntext, Block an mit dem Text', () => {
assert.ok(an.text.includes('Gesundheitsbereich prüfen: Branche Physiotherapie: Gesundheitsdaten möglich'), an.text)
})
+// ------------------------------------------------------- T16 Block gesundheit (Termine, Task D4) ---
+const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
+const GESUNDHEIT_ZEILE = 'Gesundheitsbereich prüfen: '
+
+/** Einstellung eines Salons mit den Namen und Beschreibungen der Leistungen, die der Test braucht. */
+function salon(leistungen = []) {
+ const e = ausVorlage('salon', { name: 'Salon Haarlinie', email: 'info@haarlinie.example', telefon: '06301 1234', adresse: 'Hauptstraße 1, 67722 Winnweiler', bundesland: 'RP' }, new Date('2026-10-05T10:00:00Z'))
+ leistungen.forEach(([name, beschreibung], i) => Object.assign(e.leistungen[i], { name, beschreibung: beschreibung ?? '' }))
+ return e
+}
+
+/** Termine bestellen (mit einer Einstellung im termineConfig-Dokument), bezahlen und die T16-Mail liefern. */
+async function t16MitEinstellung(einstellung, { fehlerBeimLesen = false } = {}) {
+ if (!aw.docs('subscriptions').has('sub_w_mails')) {
+ aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
+ }
+ const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
+ const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
+ if (einstellung) {
+ aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(einstellung), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
+ }
+ // der erste Lesezugriff gehoert dem Ticket (Kurzfassung), der zweite dem Hinweis fuer T16
+ if (fehlerBeimLesen) aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 })
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
+ const gesundheit = t16[0].text.split('\n').find((z) => z.startsWith(GESUNDHEIT_ZEILE))
+ return { mail: t16[0], doc: await sf.getFeatureDoc('c1', projectId, 'termine'), gesundheit: gesundheit ? gesundheit.slice(GESUNDHEIT_ZEILE.length) : null }
+}
+
+test('T16 Termine mit Leistung Physiotherapie: Warnblock "Gesundheitsbereich prüfen" mit dem Fundort, Ticket und Mail gehen raus', async () => {
+ const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]))
+ assert.equal(gesundheit, 'Leistungsname enthält „Physiotherapie“')
+ assert.ok(mail.html.includes('Gesundheitsbereich prüfen: Leistungsname enthält „Physiotherapie“'), mail.html)
+ assert.ok(doc.ticketId && aw.docs('workorders').get(doc.ticketId).details.includes('Gesundheitsbereich prüfen:'), 'das Ticket nennt es ebenfalls')
+ // die uebrigen Bloecke sind unveraendert: Ticket, Knopf zur Anleitung, kein kein_abo
+ assert.ok(mail.text.includes('Im Ticketsystem öffnen:'))
+ assert.ok(!mail.text.includes('Kein laufendes Wartungs-Abo'))
+})
+
+test('T16 Termine ohne Treffer oder ohne Einstellungen: kein Gesundheitsblock', async () => {
+ const ohneTreffer = await t16MitEinstellung(salon([['Herrenschnitt', 'Schneiden und Föhnen']]))
+ assert.equal(ohneTreffer.gesundheit, null)
+ assert.ok(!ohneTreffer.mail.text.includes('Gesundheitsbereich'))
+ const ohneConfig = await t16MitEinstellung(null)
+ assert.equal(ohneConfig.gesundheit, null)
+ assert.ok(!ohneConfig.mail.text.includes('Gesundheitsbereich') && !ohneConfig.mail.html.includes('Gesundheitsbereich'))
+})
+
+test('T16 Termine: scheitert das Lesen der Einstellung, geht T16 trotzdem ohne Block raus (nie Geld ohne Spur)', async () => {
+ const protokoll = []
+ const alt = console.error
+ console.error = (...args) => protokoll.push(args.join(' '))
+ try {
+ const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]), { fehlerBeimLesen: true })
+ assert.equal(gesundheit, null)
+ assert.ok(mail.text.includes('Neue Funktion bestellt: Terminplaner') && mail.text.includes('Im Ticketsystem öffnen:'))
+ assert.ok(doc.ticketId, 'das Ticket entstand')
+ assert.equal(protokoll.filter((z) => /Gesundheitshinweis/.test(z)).length, 1, protokoll.join('\n'))
+ assert.ok(!protokoll.join('\n').includes('Physio'), 'das Log nennt keine Daten des Betriebs')
+ } finally {
+ console.error = alt
+ }
+})
+
+test('T16 Termine: Namen des Kunden werden als Text eingesetzt (HTML maskiert, nichts Halbes), bei vielen Treffern hoechstens 500 Zeichen', async () => {
+ const boese = salon([
+ [`
\uD83D${'x'.repeat(5)}`, 'Physiotherapie'],
+ ['Zeile1\n## Zeile2', 'Therapie'],
+ ])
+ const eins = await t16MitEinstellung(boese)
+ assert.ok(eins.gesundheit.includes('
'), eins.gesundheit)
+ assert.ok(!eins.mail.html.includes('
[`${String(i)} ${'N'.repeat(36)}`, 'Physiotherapie']))
+ viele.leistungen.push(...Array.from({ length: 7 }, (_, i) => ({ ...viele.leistungen[0], id: `s_v${i}`, name: `${String(i + 4)} ${'M'.repeat(36)}`, beschreibung: 'Physiotherapie' })))
+ const zehn = await t16MitEinstellung(viele)
+ assert.ok(zehn.gesundheit.length <= 500, String(zehn.gesundheit.length))
+ assert.match(zehn.gesundheit, / … und \d+ weitere$/)
+ assert.ok(zehn.gesundheit.startsWith('Beschreibung der Leistung „0 NNN'), zehn.gesundheit)
+})
+
+test('T16 Termine, Zahlung fehlgeschlagen: kein Gesundheitsblock (es wurde nichts bestellt), auch mit Treffer in den Einstellungen', async () => {
+ const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
+ const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
+ aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, 'termine'))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1)
+ assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
+ assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
+})
+
+test('T16 News: nie ein Gesundheitsblock, auch wenn unter der ID der News-Funktion ein Einstellungsdokument mit Treffer laege', async () => {
+ const { projectId, sessionId } = await bestelle('news', 'Bäckerei Physiotherapie')
+ const doc = await sf.getFeatureDoc('c1', projectId, 'news')
+ aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'news'))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1)
+ assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
+})
+
test('T16 anleitung: Knopf nur mit Block, Link trägt &anleitung=', () => {
const link = reg.anleitungsLink('c1', 'p42')
assert.ok(link.endsWith('&anleitung=p42'))
diff --git a/test/featureTicket.test.js b/test/featureTicket.test.js
index bcf464b..b5684df 100644
--- a/test/featureTicket.test.js
+++ b/test/featureTicket.test.js
@@ -210,24 +210,15 @@ test('Termine-Ticket aus dem Register', async () => {
assert.ok(ticket.details.includes(`?tab=funktionen&anleitung=${projectId}`))
assert.ok(ticket.details.includes('Kunde: Kunde Eins'))
assert.ok(ticket.details.includes('Domain: haarlinie.de'))
- assert.match(ticket.details, /Einrichtung bezahlt \(149 €\), Zeit nicht auf das Stundenkontingent anrechnen\.$/)
+ // Task D4: die Kurzfassung der Einbauanleitung (ohne Einstellungen: Kopf, Snippet, Schritte, Verweis); Inhalt in test/termineTicket.test.js
+ assert.ok(ticket.details.includes('Funktion: Terminplaner\nProjekt: Salon Haarlinie\nDomain: haarlinie.de\nKunde: Kunde Eins\n'), ticket.details)
+ assert.ok(ticket.details.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'))
+ assert.ok(ticket.details.includes(`data-webklar-termine="${doc.siteKey}"`))
+ assert.ok(ticket.details.includes('Nicht auf das Stundenkontingent anrechnen'))
+ assert.ok(ticket.details.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
assert.equal(ticket.customerId, 'c1')
})
-test('Stub termineTicketDetails: Minimal-Details plus Hinweis zum Stundenkontingent', async () => {
- const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
- const ctx = {
- feature: reg.featureConfig('termine'),
- doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' },
- project: { projectName: 'Salon' },
- customer: null,
- }
- assert.equal(
- await termineTicketDetails(ctx),
- `${reg.minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
- )
-})
-
// ----------------------------------------------------------------------------------- Deckel ---
test('Deckel: Projektname mit 255 Zeichen', async () => {
const { sessionId } = await bestelle('termine', { projectName: 'P'.repeat(255) })
diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js
index 3c45c51..6d60cb5 100644
--- a/test/termineEinbauanleitung.test.js
+++ b/test/termineEinbauanleitung.test.js
@@ -6,8 +6,10 @@ import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einste
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from '../server/services/termine/rechtstexte.js'
import {
- ENTWURF_BANNER, cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
+ ENTWURF_BANNER, cssVorschlag, driftWarnung, einbauanleitung, einbauSnippet, esc, gesundheitsMeldungen, kontrast, kuerzeListe, luminanz, textFarbeAuf,
+ ticketStand, wochenplanText,
} from '../server/services/termine/einbauanleitung.js'
+import { TICKET_DETAILS_MAX, anleitungsLink } from '../server/services/featureRegistry.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
@@ -293,7 +295,8 @@ test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, kein
}
for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id)
assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen')
- assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4')
+ assert.equal(typeof r.kurz, 'string', 'die Kurzfassung steht seit D4 im Ergebnis (Inhalt: Tests ab "D4")')
+ assert.ok(r.kurz.length > 0)
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b)
const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du')
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`))
@@ -1095,7 +1098,7 @@ test('D3b ohne Einstellung: nur der Abschnitt snippet (Modus aus dem Fragebogen)
assert.ok(r.abschnitte[0].text.includes('Termine telefonisch vereinbaren.'), 'ohne Einstellung gibt es keine Telefonnummer')
assert.equal(r.text, `## ${r.abschnitte[0].titel}\n${r.abschnitte[0].text}`)
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
- assert.equal(r.kurz, '')
+ assert.ok(r.kurz.includes(r.abschnitte[0].text), 'die Kurzfassung (D4) traegt auch hier das Snippet')
const knopf = erzeuge({ einstellung: null, fragebogen: KNOPF_FRAGEBOGEN, version: 0, entwurf: false })
assert.ok(knopf.abschnitte[0].text.includes('data-modus="knopf" data-knopf="Termin sichern"'), knopf.abschnitte[0].text)
@@ -1133,3 +1136,360 @@ test('D3b Volltext hat die Abschnitte in der Reihenfolge der Tabelle und nur Tex
assert.ok(stellen.every((i) => i >= 0))
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
})
+
+// ================================================================ Kurzfassung fuers Ticket, ticketStand, driftWarnung (Task D4)
+// `kurz` ist Klartext fuer ticket.details (WOMS zeigt pre-wrap): Absaetze durch Leerzeile, erster Absatz = Kopf, hoechstens
+// TICKET_DETAILS_MAX Zeichen, nie ein Datenschutz-Absatz und nie ein Token.
+
+const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? text.split('\n\n')
+const absatzMit = (kurz, anfang) => absaetze(kurz).find((a) => a.startsWith(anfang))
+const LINK = anleitungsLink('c1', 'p1')
+const GESUNDHEIT_KOPF = 'Gesundheitsbereich prüfen:'
+
+/** Betrieb mit sechs Warnungen (Banner kommt ueber entwurf: true): Telefon, Anschrift, Impressum, Datenschutz-URL, Kontrast. */
+function mitSechsWarnungen() {
+ const e = fixtureEinstellung()
+ e.betrieb.telefon = ''
+ e.betrieb.adresse = ''
+ e.betrieb.impressumUrl = ''
+ e.betrieb.datenschutzUrl = ''
+ e.farbe = '#ffff00'
+ return e
+}
+
+/** Maximalbefuellung: 50 Leistungen mit 100 Zeichen (die ersten zehn mit Gesundheitswort), 20 Personen, 5 Zusatzfelder, sechs Warnungen. */
+function maximalEinstellung() {
+ const e = mitSechsWarnungen()
+ const vorlage = e.leistungen[0]
+ e.leistungen = Array.from({ length: 50 }, (_, i) => ({
+ ...vorlage,
+ id: `s_max${String(i).padStart(5, '0')}`,
+ name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
+ aktiv: true,
+ ressourcen: [],
+ beschreibung: i < 10 ? 'Physiotherapie' : '',
+ }))
+ e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
+ id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
+ }))
+ e.gastFelder = {
+ telefon: 'pflicht',
+ nachricht: 'optional',
+ zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
+ }
+ return e
+}
+const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
+
+test('D4 kurz: der erste Absatz ist der Kopf und endet mit der Konfigurationsstand-Zeile', () => {
+ const r = erzeuge({ entwurf: false })
+ const [kopf] = absaetze(r.kurz)
+ assert.deepEqual(kopf.split('\n'), [
+ 'Funktion: Terminplaner',
+ 'Projekt: Salon Muster Website',
+ 'Domain: salon-muster.example',
+ 'Kunde: Salon Muster GbR',
+ `siteKey: ${SITE_KEY}`,
+ 'Konfigurationsstand: Version 3, 03.10.2026',
+ ])
+ assert.equal(ticketStand(r.kurz), 3, 'ticketStand liest die erzeugte Zeile zurueck')
+})
+
+test('D4 kurz Kopf: Domain wie createFeatureTicket (liveDomain, sonst previewUrl, sonst subdomain), Projektname faellt auf die subdomain zurueck', () => {
+ const kopf = (project) => absaetze(erzeuge({ project }).kurz)[0].split('\n').slice(1, 3)
+ assert.deepEqual(kopf(PROJEKT), ['Projekt: Salon Muster Website', 'Domain: salon-muster.example'])
+ assert.deepEqual(kopf({ projectName: 'P', subdomain: 's', previewUrl: 'https://s.project.webklar.com' }), ['Projekt: P', 'Domain: https://s.project.webklar.com'])
+ assert.deepEqual(kopf({ projectName: 'P', subdomain: 's' }), ['Projekt: P', 'Domain: s'])
+ assert.deepEqual(kopf({ subdomain: 's' }), ['Projekt: s', 'Domain: s'])
+ assert.deepEqual(kopf(undefined), ['Projekt: ', 'Domain: '])
+ // lange Werte und Umbrueche: eine Zeile, hoechstens 120 Zeichen
+ const lang = kopf({ projectName: `Name\nmit\tUmbruch ${'x'.repeat(200)}`, liveDomain: 'd'.repeat(300) })
+ assert.ok(lang[0].startsWith('Projekt: Name mit Umbruch xxx'), lang[0])
+ assert.ok(lang[0].length <= 'Projekt: '.length + 120 && lang[0].endsWith('…'), lang[0])
+ assert.ok(lang[1].length <= 'Domain: '.length + 120 && lang[1].endsWith('…'), lang[1])
+})
+
+test('D4 kurz Konfigurationsstand: Version und Datum, ohne Datum nur die Version, ohne Einstellungen Version 0, unlesbare Einstellungen', () => {
+ const stand = (zusatz) => absaetze(erzeuge(zusatz).kurz)[0].split('\n').at(-1)
+ assert.equal(stand({}), 'Konfigurationsstand: Version 3, 03.10.2026')
+ assert.equal(stand({ updatedAt: null }), 'Konfigurationsstand: Version 3')
+ assert.equal(stand({ updatedAt: 'kein Datum' }), 'Konfigurationsstand: Version 3')
+ assert.equal(stand({ updatedAt: '2026-12-31T23:30:00.000Z' }), 'Konfigurationsstand: Version 3, 01.01.2027', 'Berliner Kalendertag')
+ assert.equal(stand({ einstellung: null, version: 0, updatedAt: null }), 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)')
+ assert.equal(stand({ einstellung: null, version: 4, updatedAt: '2026-10-03T08:15:00.000Z' }), 'Konfigurationsstand: Version 4 (Einstellungen nicht lesbar)')
+ assert.equal(ticketStand(erzeuge({ einstellung: null, version: 0 }).kurz), 0)
+})
+
+test('D4 kurz: Snippet, 6 Schritte in fester Reihenfolge, Verweis; kein Datenschutz-Absatz, kein Token', () => {
+ const e = fixtureEinstellung()
+ const r = erzeuge({ einstellung: e, entwurf: false, feature: PAKET_FEATURE })
+ const snippet = abschnitt(r, 'snippet').text
+ assert.ok(snippet.includes(`data-webklar-termine="${SITE_KEY}"`))
+ assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${snippet}`), 'das Snippet steht unveraendert und mit Ueberschrift im Ticket')
+ const schritte = absatzMit(r.kurz, 'Schritte:').split('\n').slice(1)
+ assert.equal(schritte.length, 6, schritte.join('\n'))
+ schritte.forEach((s, i) => assert.ok(s.startsWith(`${i + 1}. `), s))
+ const reihenfolge = [/WOMS/, /Einbau-Code/, /Kontrast/, /Datenschutz-Absatz/, /„Freischalten \(Abnahme\)“.*Probebuchung.*stornier/, /Ticket erledigen.*Zeit.*Nicht auf das Stundenkontingent anrechnen/]
+ reihenfolge.forEach((muster, i) => assert.match(schritte[i], muster, schritte[i]))
+ assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`), 'der Verweis steht zuletzt')
+ assert.ok(!r.kurz.includes('Online-Terminbuchung'), 'der Datenschutz-Absatz gehoert nicht ins Ticket')
+ assert.ok(!r.kurz.includes(datenschutzAbsatz(e, 'sie').slice(0, 80)))
+ for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken), 'kein Kalender-Token')
+ assert.ok(!/feedtoken/i.test(r.kurz))
+ assert.ok(!r.kurz.includes(e.betrieb.email), 'die Betriebs-E-Mail steht nur in den Datenschutz-Absaetzen')
+})
+
+test('D4 kurz Schritt 4 und 6: Datenschutz-Fassung nach Anrede, Preis nur bei bezahlter Einrichtung, die Pflichtformel steht immer', () => {
+ const schritt = (zusatz, n) => absatzMit(erzeuge(zusatz).kurz, 'Schritte:').split('\n')[n]
+ const du = fixtureEinstellung()
+ du.anrede = 'du'
+ assert.match(schritt({}, 4), /Sie-Form/)
+ assert.match(schritt({ einstellung: du }, 4), /Du-Form/)
+ assert.ok(schritt({}, 4).includes('Absatz per Chat an den Kunden senden'), 'Zwischenregel aus der Checkliste (F10b2 ersetzt sie an einer Stelle)')
+ assert.match(schritt({ feature: PAKET_FEATURE }, 6), /Nicht auf das Stundenkontingent anrechnen \(Einrichtung bezahlt, 149 €\)/)
+ assert.ok(schritt({}, 6).includes('Nicht auf das Stundenkontingent anrechnen'))
+ assert.ok(!schritt({}, 6).includes('bezahlt'), 'ohne Kauf keine Behauptung, die Einrichtung sei bezahlt')
+ for (const unlock of [undefined, null, 1, 'true']) assert.ok(!schritt({ feature: { ...PAKET_FEATURE, unlockedByPurchase: unlock } }, 6).includes('bezahlt'), String(unlock))
+})
+
+test('D4 kurz Verweis: anleitungsLink des Projekts, ohne IDs der allgemeine Hinweis', () => {
+ assert.ok(erzeuge().kurz.endsWith(`Verweis: ${LINK}`))
+ assert.equal(LINK, `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p1`)
+ const ohne = erzeuge({ feature: { $id: 'f1', siteKey: SITE_KEY } })
+ assert.ok(ohne.kurz.endsWith('Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'), ohne.kurz.slice(-120))
+ assert.ok(!ohne.kurz.includes('?tab='))
+})
+
+test('D4 kurz Kundenwunsch: Modus, Knopf-Text, Stelle, Gestaltung und Bemerkung als Text des Kunden (eingerueckt), ohne Fragebogen der Standard', () => {
+ const mit = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
+ const block = absatzMit(mit.kurz, 'Kundenwunsch')
+ assert.deepEqual(block.split('\n'), [
+ 'Kundenwunsch (Text des Kunden):',
+ ' Modus: knopf',
+ ' Knopf-Text: Termin sichern',
+ ' Stelle: Startseite unter dem Hero',
+ ' Gestaltung: Schlicht, wie die Seite',
+ ' Bemerkung: ',
+ ])
+ assert.ok(mit.kurz.includes('data-modus="knopf" data-knopf="Termin sichern"'))
+ assert.equal(mit.kurz.split('alert(1)').length - 1, 1, 'der Wunsch steht nur im Kundenwunsch-Block, nie im Snippet')
+
+ const ohne = absatzMit(erzeuge({ fragebogen: null }).kurz, 'Kundenwunsch').split('\n')
+ assert.deepEqual(ohne, ['Kundenwunsch (Text des Kunden):', ' Modus: inline (Standard)', ' Sonst keine Angaben im Fragebogen'])
+
+ // mehrzeilig: weitere Zeilen eingerueckt, Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext), keine Ueberschrift, kein falscher Stand
+ const mehrKurz = erzeuge({ fragebogen: { einbau: { modus: 'inline', bemerkung: 'Eins\n\n\nZwei\nKonfigurationsstand: Version 99' } } }).kurz
+ assert.deepEqual(absatzMit(mehrKurz, 'Kundenwunsch').split('\n').slice(2), [' Bemerkung: Eins', ' Zwei', ' Konfigurationsstand: Version 99'])
+ assert.ok(!/^Konfigurationsstand: Version 99/m.test(mehrKurz), 'der Wunsch kann den Stand nicht faelschen')
+ assert.equal(ticketStand(mehrKurz), 3)
+ assert.equal(absaetze(mehrKurz).length, absaetze(erzeuge().kurz).length, 'die Absatzzahl aendert sich durch den Wunsch nicht')
+})
+
+test('D4 kurz Zaehlungen: Anzahl, aktive und die ersten 8 Namen, danach "und N weitere"', () => {
+ const umfang = absatzMit(erzeuge().kurz, 'Umfang:').split('\n')
+ assert.deepEqual(umfang, [
+ 'Umfang:',
+ '- 4 Leistungen (3 aktiv): Herrenschnitt, Damenschnitt, Kinderschnitt, Färben',
+ '- 3 Personen und Plätze (2 aktiv): Anna, Ben, Stuhl 2',
+ '- 2 Zusatzfelder: Haarlänge, Wunschfrisur',
+ ])
+ const e = fixtureEinstellung()
+ e.leistungen = Array.from({ length: 12 }, (_, i) => ({ ...e.leistungen[0], id: `s_z${i}`, name: `Leistung ${i + 1}`, aktiv: i !== 3 }))
+ const zeile12 = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
+ assert.equal(zeile12, '- 12 Leistungen (11 aktiv): Leistung 1, Leistung 2, Leistung 3, Leistung 4, Leistung 5, Leistung 6, Leistung 7, Leistung 8, … und 4 weitere')
+ e.leistungen = e.leistungen.slice(0, 8)
+ assert.ok(!absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').includes('weitere'), 'genau 8: nichts uebrig')
+ e.leistungen = e.leistungen.slice(0, 1)
+ e.ressourcen = []
+ e.gastFelder = { zusatz: [] }
+ assert.deepEqual(absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n').slice(1), ['- 1 Leistung (1 aktiv): Leistung 1', '- 0 Personen und Plätze', '- 0 Zusatzfelder'])
+ // sehr lange Namen: je Name hoechstens 60 Zeichen, Umbrueche werden zu Leerzeichen
+ e.leistungen = [{ ...e.leistungen[0], name: `${'N'.repeat(100)}\nzwei` }]
+ const lang = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
+ assert.ok(lang.endsWith(`${'N'.repeat(59)}…`), lang)
+ assert.ok(!lang.includes('zwei') && !lang.includes('N'.repeat(60)))
+})
+
+test('D4 kurz Warnungen: hoechstens 5 in der Reihenfolge von warnungen, das Banner nur einmal, ohne Warnung kein Absatz', () => {
+ const r = erzeuge({ einstellung: mitSechsWarnungen(), entwurf: true })
+ assert.equal(r.warnungen.length, 6, r.warnungen.join('\n'))
+ const block = absatzMit(r.kurz, 'Warnungen:').split('\n')
+ assert.deepEqual(block, ['Warnungen:', ...r.warnungen.slice(0, 5).map((w) => `- ${w}`)], 'die ersten fuenf, die sechste (Kontrast) bleibt dem Panel')
+ assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1, 'der Banner-Text steht genau einmal im Ticket')
+ assert.equal(block[1], `- ${ENTWURF_BANNER}`)
+ const ohne = erzeuge({ entwurf: false })
+ assert.deepEqual(ohne.warnungen, [])
+ assert.ok(!ohne.kurz.includes('Warnungen:'))
+ assert.equal(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).warnungen.length, 5)
+ assert.equal(absatzMit(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).kurz, 'Warnungen:').split('\n').length, 6)
+})
+
+test('D4 kurz Gesundheit: eigener Absatz, zaehlt nicht zu den 5 Warnungen, wohlgeformt, einzeilig und als Klartext', () => {
+ const e = mitSechsWarnungen()
+ e.leistungen[0].name = 'Physiotherapie'
+ e.leistungen[1].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
+ e.leistungen[1].beschreibung = 'Therapie'
+ e.leistungen[2].name = 'Zeile1\n## Zeile2 x'
+ e.leistungen[2].beschreibung = 'Physiotherapie'
+ const r = erzeuge({ einstellung: e, entwurf: true })
+ const gesundheit = absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n')
+ const meldungen = gesundheitsMeldungen(e)
+ assert.equal(meldungen.length, 3, meldungen.join('\n'))
+ assert.deepEqual(gesundheit, [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
+ assert.equal(absatzMit(r.kurz, 'Warnungen:').split('\n').length, 6, 'die fuenf Warnungen bleiben voll, die Gesundheit kommt dazu')
+ for (const m of meldungen) {
+ assert.ok(m.isWellFormed() && !/[\r\n]/.test(m), JSON.stringify(m))
+ assert.ok(!r.warnungen.every((w) => w !== m), 'dieselben Meldungen stehen auch in warnungen')
+ }
+ assert.ok(meldungen.some((m) => m.includes('x')), 'Klartext, nichts wird maskiert')
+ assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz))
+ // keine Treffer: kein Absatz
+ assert.ok(!erzeuge().kurz.includes(GESUNDHEIT_KOPF))
+ // Reihenfolge der Absaetze
+ const stellen = ['Funktion:', 'Kundenwunsch', 'Umfang:', 'Warnungen:', GESUNDHEIT_KOPF, 'Einbau-Code (Snippet):', 'Schritte:', 'Verweis:'].map((a) => absaetze(erzeuge({ einstellung: e, entwurf: true, fragebogen: KNOPF_FRAGEBOGEN }).kurz).findIndex((x) => x.startsWith(a)))
+ assert.ok(stellen.every((i) => i >= 0), JSON.stringify(stellen))
+ assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b))
+})
+
+test('D4 gesundheitsMeldungen: ohne Treffer und ohne Einstellung leer, sonst die bereinigten Meldungen der Pruefung aus C', () => {
+ assert.deepEqual(gesundheitsMeldungen(null), [])
+ assert.deepEqual(gesundheitsMeldungen(undefined), [])
+ assert.deepEqual(gesundheitsMeldungen(fixtureEinstellung()), [])
+ const e = fixtureEinstellung()
+ e.leistungen[0].name = 'Physiotherapie'
+ assert.deepEqual(gesundheitsMeldungen(e), ['Leistungsname enthält „Physiotherapie“'])
+ const halb = fixtureEinstellung()
+ halb.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
+ halb.leistungen[0].beschreibung = 'Physiotherapie'
+ for (const m of gesundheitsMeldungen(halb)) assert.ok(m.isWellFormed(), JSON.stringify(m))
+})
+
+test('D4 kurz ohne Einstellungen: Kopf mit Version 0, Warnung, Snippet, Schritte und Verweis', () => {
+ const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: true })
+ const [kopf] = absaetze(r.kurz)
+ assert.ok(kopf.endsWith('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), kopf)
+ assert.ok(r.kurz.includes('noch keine Einstellungen'))
+ assert.deepEqual(absatzMit(r.kurz, 'Warnungen:').split('\n'), ['Warnungen:', `- ${ENTWURF_BANNER}`, '- Keine Einstellungen gespeichert'])
+ assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${r.abschnitte[0].text}`))
+ assert.ok(!r.kurz.includes('Umfang:') && !r.kurz.includes(GESUNDHEIT_KOPF))
+ assert.equal(absatzMit(r.kurz, 'Schritte:').split('\n').length, 7)
+ assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
+ assert.ok(r.kurz.length <= TICKET_DETAILS_MAX)
+ // der Wunsch des Kunden (Fragebogen) steht auch ohne Einstellungen im Ticket
+ assert.ok(erzeuge({ einstellung: null, version: 0, fragebogen: KNOPF_FRAGEBOGEN }).kurz.includes(' Knopf-Text: Termin sichern'))
+})
+
+test('D4 kurz Maximalbefuellung: hoechstens TICKET_DETAILS_MAX, Kopf, Snippet, Verweis, Stundenkontingent und alle 10 Gesundheitswarnungen bleiben', () => {
+ const e = maximalEinstellung()
+ const meldungen = gesundheitsMeldungen(e)
+ assert.equal(meldungen.length, 10, 'die Pruefung aus C liefert hoechstens 10')
+ const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
+ assert.ok(r.kurz.length <= TICKET_DETAILS_MAX, `kurz ${r.kurz.length}`)
+ assert.ok(r.kurz.length > 3000, 'es steht etwas Sinnvolles da, nicht nur ein Rumpf')
+ assert.equal(absaetze(r.kurz)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
+ assert.ok(r.kurz.includes(abschnitt(r, 'snippet').text))
+ assert.ok(r.kurz.includes('Nicht auf das Stundenkontingent anrechnen'))
+ assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
+ assert.deepEqual(absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n'), [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
+ assert.ok(!r.kurz.includes('Online-Terminbuchung'))
+ for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken))
+ assert.ok(r.kurz.isWellFormed())
+ assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1)
+})
+
+test('D4 kurz Platzdruck: erst fallen die Zaehlungen, dann die Warnungen, dann der Kundenwunsch weg; Pflichtteile und Gesundheit nie', () => {
+ const gesehen = new Set()
+ let zuletzt = null
+ // das Snippet waechst mit dem (hier absichtlich nicht normalisierten) Telefon und treibt die Pflichtteile nach oben
+ for (let laenge = 1; laenge <= 3200; laenge += 8) {
+ const e = maximalEinstellung()
+ e.betrieb.telefon = '0'.repeat(laenge)
+ e.betrieb.adresse = ''
+ const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
+ const k = r.kurz
+ assert.ok(k.length <= TICKET_DETAILS_MAX, `Telefon ${laenge}: kurz ${k.length}`)
+ // Pflichtteile
+ assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
+ assert.ok(k.includes(abschnitt(r, 'snippet').text), `Telefon ${laenge}: Snippet`)
+ assert.ok(k.includes('Nicht auf das Stundenkontingent anrechnen') && k.endsWith(`Verweis: ${LINK}`))
+ assert.deepEqual(absatzMit(k, GESUNDHEIT_KOPF).split('\n').slice(1), gesundheitsMeldungen(e).map((m) => `- ${m}`), `Telefon ${laenge}: Gesundheit`)
+ // Stufen
+ const umfang = k.includes('\n\nUmfang:')
+ const warnungen = k.includes('\n\nWarnungen:')
+ const wunsch = k.includes('\n\nKundenwunsch (Text des Kunden):')
+ if (umfang) assert.ok(warnungen && wunsch, `Telefon ${laenge}: Zaehlungen nur mit Warnungen und Wunsch`)
+ if (warnungen) assert.ok(wunsch, `Telefon ${laenge}: Warnungen nur mit Wunsch`)
+ if (warnungen) {
+ const anzahl = absatzMit(k, 'Warnungen:').split('\n').length - 1
+ assert.deepEqual(absatzMit(k, 'Warnungen:').split('\n').slice(1), r.warnungen.filter((w) => !gesundheitsMeldungen(e).includes(w)).slice(0, anzahl).map((w) => `- ${w}`), 'es fallen die letzten Warnungen zuerst weg')
+ }
+ gesehen.add(`${umfang ? 'Umfang' : '-'}/${warnungen ? 'Warnungen' : '-'}/${wunsch ? 'Wunsch' : '-'}`)
+ zuletzt = k
+ }
+ assert.ok(gesehen.has('Umfang/Warnungen/Wunsch'), [...gesehen].join(' '))
+ assert.ok(gesehen.has('-/Warnungen/Wunsch'), [...gesehen].join(' '))
+ assert.ok(gesehen.has('-/-/Wunsch'), [...gesehen].join(' '))
+ assert.ok(gesehen.has('-/-/-'), [...gesehen].join(' '))
+ assert.ok(zuletzt.length > 0)
+})
+
+test('D4 kurz: selbst ein riesiges Snippet sprengt die Grenze nicht, die Gesundheit steht vor dem Schnitt', () => {
+ const e = maximalEinstellung()
+ e.betrieb.telefon = '9'.repeat(20000)
+ const k = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, entwurf: true }).kurz
+ assert.ok(k.length <= TICKET_DETAILS_MAX, `kurz ${k.length}`)
+ assert.ok(k.isWellFormed() && !HALBES_PAAR.test(k))
+ assert.ok(k.includes(GESUNDHEIT_KOPF))
+ assert.ok(k.endsWith('… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'))
+ assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
+})
+
+test('D4 kurz ist wohlgeformt: halbe Zeichenpaare in Namen, Wuenschen, Projekt und Kunde werden ersetzt, Emojis bleiben', () => {
+ const e = fixtureEinstellung()
+ e.leistungen[0].name = `Schnitt \uD83D${'x'.repeat(10)}😀`
+ e.ressourcen[0].name = `Anna\uDE00`
+ const fb = { einbau: { modus: 'inline', bemerkung: `halb \uD83D und ganz 😀`, stelle: '\uDE00' } }
+ const r = erzeuge({ einstellung: e, fragebogen: fb, project: { projectName: `Salon \uD83D`, liveDomain: 'x.de' }, kunde: { name: `K\uDE00unde` } })
+ assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz), JSON.stringify(r.kurz.match(HALBES_PAAR)))
+ assert.ok(r.kurz.includes('😀'), 'ganze Zeichenpaare bleiben')
+ assert.ok(JSON.parse(JSON.stringify(r.kurz)) === r.kurz)
+})
+
+test('D4 kurz ist deterministisch, unabhaengig von now und aendert die Eingaben nicht', () => {
+ const e = maximalEinstellung()
+ const fb = structuredClone(MAX_FRAGEBOGEN)
+ const vorher = structuredClone({ e, fb })
+ const a = erzeuge({ einstellung: e, fragebogen: fb })
+ assert.equal(erzeuge({ einstellung: e, fragebogen: fb }).kurz, a.kurz)
+ assert.equal(erzeuge({ einstellung: e, fragebogen: fb, now: SPAETER }).kurz, a.kurz, 'in der Kurzfassung steht kein Erzeugungsdatum')
+ assert.deepEqual(structuredClone({ e, fb }), vorher)
+})
+
+// ---------------------------------------------------------------- ticketStand, driftWarnung
+
+test('D4 ticketStand liest Konfigurationsstand: Version N am Zeilenanfang, sonst null', () => {
+ assert.equal(ticketStand('Funktion: Terminplaner\nKonfigurationsstand: Version 7, 03.10.2026\n\nrest'), 7)
+ assert.equal(ticketStand('Konfigurationsstand: Version 7, 03.10.2026'), 7)
+ assert.equal(ticketStand('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), 0)
+ assert.equal(ticketStand('x\r\nKonfigurationsstand: Version 12'), 12)
+ assert.equal(ticketStand('Konfigurationsstand: Version 999999999'), 999999999, 'neun Stellen sind erlaubt')
+ assert.equal(ticketStand('Konfigurationsstand: Version 99999999999'), null, 'elf Stellen: kein Stand')
+ assert.equal(ticketStand('Konfigurationsstand: Version 1234567890'), null, 'zehn Stellen: kein Stand')
+ for (const ohne of ['', 'Funktion: Terminplaner', 'Konfigurationsstand: Version', 'Konfigurationsstand: Version x', ' Konfigurationsstand: Version 7', 'Der Konfigurationsstand: Version 7', 'konfigurationsstand: Version 7', 'Konfigurationsstand: Version -7']) {
+ assert.equal(ticketStand(ohne), null, JSON.stringify(ohne))
+ }
+ for (const nichtText of [undefined, null, 7, {}, ['Konfigurationsstand: Version 7']]) assert.equal(ticketStand(nichtText), null, String(nichtText))
+})
+
+test('D4 driftWarnung: nur wenn die Einstellungen jetzt eine hoehere Version haben als das Ticket', () => {
+ assert.equal(driftWarnung(7, 9), 'Einstellungen seit der Bestellung geändert (Ticket Version 7, jetzt 9).')
+ assert.equal(driftWarnung(0, 1), 'Einstellungen seit der Bestellung geändert (Ticket Version 0, jetzt 1).', 'Version 0 im Ticket ist ein gueltiger Stand')
+ assert.equal(driftWarnung(9, 9), null)
+ assert.equal(driftWarnung(9, 7), null)
+ assert.equal(driftWarnung(null, 9), null)
+ assert.equal(driftWarnung(7, null), null)
+ assert.equal(driftWarnung(undefined, 9), null)
+ assert.equal(driftWarnung('7', 9), null)
+ assert.equal(driftWarnung(7.5, 9), null)
+})
diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js
index 011815c..b030530 100644
--- a/test/termineKontext.test.js
+++ b/test/termineKontext.test.js
@@ -31,7 +31,7 @@ const { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import(
const { activate } = await import('../server/services/siteFeatures.js')
const K = await import('../server/services/termine/kontext.js')
const M = await import('../server/services/termine/mails.js')
-const { ladeEinstellung, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K
+const { ladeEinstellung, ladeStand, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K
const { BETREFFE, werteFuer, anGast, anBetrieb } = M
after(() => setMailTransportForTests(null))
@@ -266,6 +266,78 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh
})
})
+// ---------------------------------------------------------------- ladeStand (TP2 D4: Stand fuer das Einrichtungs-Ticket)
+
+test('ladeStand: ohne termineConfig-Dokument null, mit Dokument Einstellung, Version, updatedAt und der Fragebogen als gespeicherte Angaben', async () => {
+ assert.equal(await ladeStand(FEATURE), null, 'noch nichts gespeichert')
+ const e = einstellung()
+ const fb = frage({ branche: 'salon', bestaetigt: ['branche'], einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } })
+ await speichereEinstellung(feature(), e, { version: 0, now: JETZT, fragebogen: fb })
+ const stand = await ladeStand(FEATURE)
+ assert.deepEqual(Object.keys(stand).sort(), ['einstellung', 'fragebogen', 'updatedAt', 'version'])
+ assert.deepEqual(stand.einstellung, e)
+ assert.equal(stand.version, 1)
+ assert.equal(new Date(stand.updatedAt).getTime(), JETZT.getTime())
+ assert.deepEqual(stand.fragebogen, JSON.parse(gespeichert().fragebogen))
+ assert.equal(stand.fragebogen.einbau.knopfText, 'Termin buchen')
+ await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }), { version: 1, now: SEKUNDEN(60) })
+ const zweit = await ladeStand(FEATURE)
+ assert.equal(zweit.version, 2)
+ assert.equal(zweit.einstellung.betrieb.name, 'Salon Neu')
+ assert.equal(new Date(zweit.updatedAt).getTime(), SEKUNDEN(60).getTime())
+ assert.deepEqual(zweit.fragebogen, stand.fragebogen, 'der Fragebogen bleibt, wenn nur die Einstellung gespeichert wird')
+ assert.equal(log.error.mock.callCount(), 0, 'lesbare Dokumente: keine Logzeile')
+})
+
+test('ladeStand: kaputtes fragebogen-JSON gibt fragebogen null bei gueltiger Einstellung (mit Log), kaputte Einstellung gibt einstellung null mit der Version', async () => {
+ const e = einstellung()
+ aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: '{kaputt' })
+ const stand = await ladeStand(FEATURE)
+ assert.deepEqual(stand.einstellung, e)
+ assert.equal(stand.fragebogen, null)
+ assert.equal(stand.version, 2)
+ assert.equal(log.error.mock.callCount(), 1)
+ assert.match(String(log.error.mock.calls[0].arguments[0]), /Fragebogen f_p_projekt01_termine/)
+
+ const fb = frage({ branche: 'salon' })
+ await deleteDocument('termineConfig', FEATURE)
+ aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: JSON.stringify(fb) })
+ const kaputt = await ladeStand(FEATURE)
+ assert.equal(kaputt.einstellung, null)
+ assert.equal(kaputt.version, 3, 'die Version des Dokuments bleibt')
+ assert.deepEqual(kaputt.fragebogen, fb)
+ assert.equal(log.error.mock.callCount(), 2)
+})
+
+test('ladeStand: fehlendes oder leeres fragebogen-Attribut, ein Nicht-Objekt und fehlende Zeit ergeben null; Version ohne Zahl ist 0', async () => {
+ const e = einstellung()
+ const doc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 1, updatedAt: JETZT.toISOString(), updatedBy: '' }
+ for (const fragebogen of [undefined, null, '']) {
+ await deleteDocument('termineConfig', FEATURE).catch(() => {})
+ aw.seed('termineConfig', { ...doc, ...(fragebogen === undefined ? {} : { fragebogen }) })
+ assert.equal((await ladeStand(FEATURE)).fragebogen, null, String(fragebogen))
+ }
+ assert.equal(log.error.mock.callCount(), 0, 'nichts gespeichert ist kein Fehler')
+ for (const roh of ['[1,2]', '"text"', '42', 'null']) {
+ await deleteDocument('termineConfig', FEATURE)
+ aw.seed('termineConfig', { ...doc, fragebogen: roh })
+ assert.equal((await ladeStand(FEATURE)).fragebogen, null, roh)
+ }
+ await deleteDocument('termineConfig', FEATURE)
+ aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e) })
+ const ohneZeit = await ladeStand(FEATURE)
+ assert.equal(ohneZeit.updatedAt, null)
+ assert.equal(ohneZeit.version, 0)
+ assert.deepEqual(ohneZeit.einstellung, e)
+})
+
+test('ladeStand: Eingabepruefung wie ladeEinstellung, fehlende Collection ist ein Fehler und kein "nichts"', async () => {
+ for (const id of [undefined, null, '', 7]) await assert.rejects(() => ladeStand(id), TypeError, String(id))
+ await mitFehlenderCollection('termineConfig', async () => {
+ await assert.rejects(() => ladeStand(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found')
+ })
+})
+
test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => {
const aufrufe = []
const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) })
diff --git a/test/termineTicket.test.js b/test/termineTicket.test.js
new file mode 100644
index 0000000..8773269
--- /dev/null
+++ b/test/termineTicket.test.js
@@ -0,0 +1,288 @@
+import { test, after, mock } from 'node:test'
+import assert from 'node:assert/strict'
+import { startAppwriteMock } from './support/appwriteMock.js'
+import { useTestEnv } from './support/testEnv.js'
+import { schemaKeys } from './support/schema.js'
+import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
+
+// Kurzfassung der Einbauanleitung im Einrichtungs-Ticket (TP2 Task D4): termineTicketDetails ist der Inhalt von
+// config.features.termine.ticket.details. Es wirft nie ("bezahlt ist bezahlt, das Ticket entsteht immer"): bei Lesefehlern
+// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht
+// an: ohne Schema im Nachbau.
+const aw = await startAppwriteMock({
+ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') },
+ datetimes: TERMINE_DATETIMES,
+})
+after(() => aw.close())
+useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
+const { config } = await import('../server/config.js')
+const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
+const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
+const reg = await import('../server/services/featureRegistry.js')
+const sf = await import('../server/services/siteFeatures.js')
+const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
+const { ticketStand, driftWarnung, gesundheitsMeldungen } = await import('../server/services/termine/einbauanleitung.js')
+
+aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
+
+const NOW = new Date('2026-10-05T10:00:00Z')
+const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
+const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? {
+ const e = ausVorlage('salon', BETRIEB, NOW)
+ e.farbe = '#9d4b6f'
+ return e
+}
+
+/** Maximalbefuellung wie im Plan: 50 Leistungen mit 100 Zeichen, 20 Personen, 5 Zusatzfelder, 10 Gesundheitsmeldungen, 6 Warnungen. */
+function maximalEinstellung() {
+ const e = einstellung()
+ e.betrieb.telefon = ''
+ e.betrieb.adresse = ''
+ e.betrieb.impressumUrl = ''
+ e.betrieb.datenschutzUrl = ''
+ e.farbe = '#ffff00'
+ const vorlage = e.leistungen[0]
+ e.leistungen = Array.from({ length: 50 }, (_, i) => ({
+ ...vorlage,
+ id: `s_max${String(i).padStart(5, '0')}`,
+ name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
+ aktiv: true,
+ ressourcen: [],
+ beschreibung: i < 10 ? 'Physiotherapie' : '',
+ }))
+ e.leistungen[0].name = 'Physiotherapie'
+ e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
+ id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
+ }))
+ e.gastFelder = {
+ telefon: 'pflicht',
+ nachricht: 'optional',
+ zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
+ }
+ return e
+}
+const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
+
+let zaehler = 0
+/** Projekt (Kunde c1) anlegen und den Terminplaner bestellen; mit e (und fb) liegt ein termineConfig-Dokument unter der Funktions-ID. */
+async function bestelle({ e = null, fb = null, version = 1, projekt = PROJEKT } = {}) {
+ zaehler += 1
+ const projectId = `tt${zaehler}`
+ const sessionId = `cs_tt${zaehler}`
+ aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', ...projekt })
+ const doc = await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
+ if (e) {
+ aw.seed('termineConfig', {
+ $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(e), version, updatedAt: UPDATED_AT, updatedBy: 'kunde:c1',
+ ...(fb ? { fragebogen: JSON.stringify(fb) } : {}),
+ })
+ }
+ return { projectId, sessionId, doc }
+}
+
+/** ctx genau in der Form, die markPaid uebergibt: { feature: Register-Eintrag, doc, project, customer }. */
+const ctxFuer = (doc, project = PROJEKT, customer = { name: 'Kunde Eins' }) => ({ feature: reg.featureConfig('termine'), doc, project, customer })
+const absaetze = (text) => text.split('\n\n')
+const stumm = () => mock.method(console, 'error', () => {})
+
+// ------------------------------------------------------------------------------ termineTicketDetails ---
+test('termineTicketDetails mit Einstellungen: Kopf mit Konfigurationsstand: Version 1, Snippet mit siteKey, Verweis, Stundenkontingent', async () => {
+ const { projectId, doc } = await bestelle({ e: einstellung() })
+ const text = await termineTicketDetails(ctxFuer(doc))
+ const [kopf] = absaetze(text)
+ assert.deepEqual(kopf.split('\n'), [
+ 'Funktion: Terminplaner',
+ 'Projekt: Salon Haarlinie',
+ 'Domain: haarlinie.example',
+ 'Kunde: Kunde Eins',
+ `siteKey: ${doc.siteKey}`,
+ 'Konfigurationsstand: Version 1, 03.10.2026',
+ ])
+ assert.ok(text.includes('Konfigurationsstand: Version 1'))
+ assert.equal(ticketStand(text), 1)
+ assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
+ assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
+ assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
+ assert.ok(text.includes('Umfang:\n- 4 Leistungen'), text)
+ assert.ok(!text.includes('Online-Terminbuchung') && !text.includes(BETRIEB.email), 'weder Datenschutz-Absatz noch Betriebs-E-Mail')
+ assert.ok(text.length <= MAX)
+ assert.ok(!text.includes('bezahlt'), 'hier wurde nur bestellt: keine Behauptung, die Einrichtung sei bezahlt')
+})
+
+test('termineTicketDetails: der Wunsch des Kunden aus dem Fragebogen steht im Ticket, der Modus im Snippet', async () => {
+ const fb = { einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht', bemerkung: 'Bitte vor dem 20.10.' } }
+ const { doc } = await bestelle({ e: einstellung(), fb })
+ const text = await termineTicketDetails(ctxFuer(doc))
+ assert.ok(text.includes(' Modus: knopf\n Knopf-Text: Termin sichern\n Stelle: Startseite unter dem Hero\n Gestaltung: Schlicht\n Bemerkung: Bitte vor dem 20.10.'), text)
+ assert.ok(text.includes('data-modus="knopf" data-knopf="Termin sichern"'))
+})
+
+test('termineTicketDetails ohne termineConfig: noch keine Einstellungen, Snippet, Verweis', async () => {
+ const { projectId, doc } = await bestelle()
+ const text = await termineTicketDetails(ctxFuer(doc))
+ assert.ok(text.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), text)
+ assert.ok(text.includes('noch keine Einstellungen'))
+ assert.equal(ticketStand(text), 0)
+ assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
+ assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
+ assert.ok(text.includes('Keine Einstellungen gespeichert'))
+})
+
+test('termineTicketDetails bei Lesefehler: loest auf, ohne Wurf, mit Kopfzeilen, Hinweis und Link', async () => {
+ const { projectId, doc } = await bestelle({ e: einstellung() })
+ const fehler = stumm()
+ try {
+ aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
+ const text = await termineTicketDetails(ctxFuer(doc))
+ const link = reg.anleitungsLink('c1', projectId)
+ for (const teil of ['Projekt: Salon Haarlinie', 'Domain: haarlinie.example', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${link}`, 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen', 'Nicht auf das Stundenkontingent anrechnen']) {
+ assert.ok(text.includes(teil), `${teil}\n---\n${text}`)
+ }
+ assert.equal(ticketStand(text), null, 'ohne Konfigurationsstand-Zeile kein Vergleich')
+ assert.equal(fehler.mock.callCount(), 1)
+ assert.ok(!String(fehler.mock.calls[0].arguments.join(' ')).includes(doc.siteKey), 'das Log nennt keine Daten des Betriebs')
+ } finally {
+ fehler.mock.restore()
+ }
+ // danach funktioniert es wieder (der Fehler war einmalig)
+ assert.ok((await termineTicketDetails(ctxFuer(doc))).includes('Konfigurationsstand: Version 1'))
+})
+
+test('termineTicketDetails wirft nie: auch nicht ohne Dokument, ohne ctx oder bei fehlender Collection', async () => {
+ const fehler = stumm()
+ try {
+ for (const ctx of [undefined, null, {}, { doc: null }, { doc: {} }, { doc: { $id: '' } }, { doc: { $id: 7 } }, ctxFuer({ $id: 'f_gibt_es_nicht', customerId: 'c1', projectId: 'p0', siteKey: SITE_KEY })]) {
+ const text = await termineTicketDetails(ctx)
+ assert.equal(typeof text, 'string', JSON.stringify(ctx))
+ assert.ok(text.includes('Terminplaner'), JSON.stringify(ctx))
+ assert.ok(text.length <= MAX && text.isWellFormed())
+ }
+ } finally {
+ fehler.mock.restore()
+ }
+})
+
+test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare werden ersetzt', async () => {
+ const e = einstellung()
+ e.leistungen[0].name = `Schnitt x \uD83D${'x'.repeat(8)}😀`
+ const projekt = { projectName: `Salon \uD83D x`, liveDomain: 'haarlinie.example' }
+ const { doc } = await bestelle({ e, projekt })
+ const text = await termineTicketDetails(ctxFuer(doc, projekt, { name: `Kunde \uDE00 & Co` }))
+ assert.ok(text.isWellFormed() && !HALBES_PAAR.test(text))
+ assert.ok(text.includes('x') && text.includes('x'), 'Klartext, kein HTML')
+ assert.ok(text.includes('😀'))
+ // ohne Kunde: die Kunden-ID wie in den Notfall-Details
+ assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1'))
+})
+
+test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => {
+ const e = maximalEinstellung()
+ const meldungen = gesundheitsMeldungen(e)
+ assert.equal(meldungen.length, 10)
+ const { doc } = await bestelle({ e, fb: MAX_FRAGEBOGEN, version: 12 })
+ const text = await termineTicketDetails(ctxFuer(doc))
+ assert.ok(text.length <= MAX, `kurz ${text.length}`)
+ assert.ok(text.includes('Physio'))
+ assert.ok(text.includes(`Gesundheitsbereich prüfen:\n${meldungen.map((m) => `- ${m}`).join('\n')}`), 'alle zehn Meldungen')
+ assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
+ assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
+ assert.equal(ticketStand(text), 12)
+ assert.ok(!text.includes('Online-Terminbuchung'))
+ for (const r of e.ressourcen) assert.ok(!text.includes(r.feedToken))
+ assert.ok(!text.includes(GEKUERZT), 'die Budgetliste laesst Teile weg und schneidet nicht ab')
+ // ticketTexte kuerzt nichts mehr
+ const texte = await reg.ticketTexte(ctxFuer(doc))
+ assert.equal(texte.details, text)
+})
+
+// ------------------------------------------------------------------------------------- Registry ---
+test('Registry: featureConfig("termine").ticket.details(ctx) liefert den Text von termineTicketDetails, siteKey und $id aus ctx.doc', async () => {
+ const { doc } = await bestelle({ e: einstellung() })
+ const feature = reg.featureConfig('termine')
+ assert.equal(feature.ticket.topic(PROJEKT), 'Terminplaner einbauen – Salon Haarlinie')
+ // das Register-Objekt traegt keinen siteKey: der Text darf nur aus ctx.doc kommen
+ const ctx = { feature: { ...feature, siteKey: 'FALSCH_AUS_REGISTER', $id: 'f_falsch' }, doc, project: PROJEKT, customer: { name: 'Kunde Eins' } }
+ const ueberRegister = await feature.ticket.details(ctx)
+ assert.equal(ueberRegister, await termineTicketDetails(ctx))
+ assert.equal(ueberRegister, await termineTicketDetails(ctxFuer(doc)))
+ assert.ok(ueberRegister.includes(doc.siteKey) && !ueberRegister.includes('FALSCH_AUS_REGISTER'))
+ assert.ok(ueberRegister.includes('Konfigurationsstand: Version 1'), 'gelesen wurde das Dokument der Funktion (doc.$id)')
+})
+
+// ----------------------------------------------------------------------- Integration (Review Focus 1) ---
+const workorder = (doc) => aw.docs('workorders').get(doc.ticketId)
+
+test('markOrdered + markPaid: das Ticket traegt die Kurzfassung (hoechstens 7000, mit Snippet), die Stand-Zeile und der Vergleich meldet Aenderungen', async () => {
+ const { sessionId, doc: bestellt } = await bestelle({ e: maximalEinstellung(), fb: MAX_FRAGEBOGEN })
+ const { doc, created, fall } = await sf.markPaid({ sessionId })
+ assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
+ const ticket = workorder(doc)
+ assert.ok(ticket.details.length <= MAX, `details ${ticket.details.length}`)
+ assert.ok(ticket.details.includes(`data-webklar-termine="${bestellt.siteKey}"`))
+ assert.ok(ticket.details.includes('Einrichtung bezahlt, 149 €'), 'markPaid uebergibt das bezahlte Dokument (unlockedByPurchase)')
+ assert.ok(ticket.details.includes('Physio'))
+ assert.ok(!ticket.details.includes(GEKUERZT))
+ assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie')
+ // Ticket Version 1; die Einstellungen aendern sich spaeter: der Vergleich (Team-Route, D8) meldet es
+ const ticketVersion = ticketStand(ticket.details)
+ assert.equal(ticketVersion, 1)
+ assert.equal(driftWarnung(ticketVersion, 1), null)
+ assert.equal(driftWarnung(ticketVersion, 4), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 4).')
+})
+
+test('markPaid mit kaputtem termineConfig-Lesen legt das Ticket trotzdem an: created true, Notfall-Details mit Kopf, Hinweis und Link', async () => {
+ const { projectId, sessionId } = await bestelle({ e: einstellung() })
+ const fehler = stumm()
+ try {
+ aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
+ const { doc, created, fall } = await sf.markPaid({ sessionId })
+ assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
+ assert.ok(doc.ticketId, 'Zahlung nie ohne Ticket')
+ const ticket = workorder(doc)
+ assert.ok(ticket.details.includes(`siteKey: ${doc.siteKey}`))
+ assert.ok(ticket.details.includes('Anleitung konnte nicht erzeugt werden, im WOMS öffnen'))
+ assert.ok(ticket.details.includes(reg.anleitungsLink('c1', projectId)))
+ assert.ok(ticket.details.length <= MAX)
+ } finally {
+ fehler.mock.restore()
+ }
+})
+
+test('Webhook-Wiederholung nach einem Lesefehler: ohne Ticket bleibt setup fortsetzbar, die Kurzfassung entsteht beim zweiten Versuch', async () => {
+ const { sessionId } = await bestelle({ e: einstellung() })
+ const fehler = stumm()
+ try {
+ // das Anlegen des Tickets scheitert (nicht das Lesen): setup ohne ticketId
+ aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
+ await assert.rejects(() => sf.markPaid({ sessionId }))
+ const { doc, created } = await sf.markPaid({ sessionId })
+ assert.equal(created, true)
+ assert.ok(workorder(doc).details.includes('Konfigurationsstand: Version 1'))
+ } finally {
+ fehler.mock.restore()
+ }
+})
+
+test('config.features.termine.ticket.details ruft die Datei lazy auf (config.js bleibt ohne statischen Dienst-Import)', async () => {
+ const { readFileSync } = await import('node:fs')
+ const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8')
+ assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/)
+ assert.ok(!/^import .*ticketDetails/m.test(src))
+ assert.equal(typeof config.features.termine.ticket.details, 'function')
+})