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') +})