Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90692b646b
commit
2db3eca357
9 files changed
+1181
-49
No files matched your search
@@ -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 !== '' }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
+112
-2
@@ -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([
|
||||
[`<img src=x onerror=alert(1)> \uD83D${'x'.repeat(5)}`, 'Physiotherapie'],
|
||||
['Zeile1\n## Zeile2', 'Therapie'],
|
||||
])
|
||||
const eins = await t16MitEinstellung(boese)
|
||||
assert.ok(eins.gesundheit.includes('<img src=x onerror=alert(1)>'), eins.gesundheit)
|
||||
assert.ok(!eins.mail.html.includes('<img src=x'), 'im HTML maskiert')
|
||||
assert.ok(eins.mail.html.includes('<img src=x onerror=alert(1)>'))
|
||||
assert.ok(eins.mail.text.isWellFormed() && eins.mail.html.isWellFormed())
|
||||
assert.ok(!eins.gesundheit.includes('\n'))
|
||||
|
||||
// 10 Treffer: gekuerzt auf ganze Meldungen und "und N weitere", hoechstens 500 Zeichen
|
||||
const viele = salon(Array.from({ length: 4 }, (_, i) => [`${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=<Projekt-ID>', () => {
|
||||
const link = reg.anleitungsLink('c1', 'p42')
|
||||
assert.ok(link.endsWith('&anleitung=p42'))
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
|
||||
const absaetze = (text) => 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: <script>alert(1)</script>',
|
||||
])
|
||||
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 <b>x</b>'
|
||||
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('<b>x</b>')), '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)
|
||||
})
|
||||
@@ -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' }) })
|
||||
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
|
||||
const MAX = reg.TICKET_DETAILS_MAX
|
||||
const BETRIEB = {
|
||||
name: 'Salon Haarlinie',
|
||||
email: 'inhaber-intern@haarlinie.example',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstraße 1, 67722 Winnweiler',
|
||||
kleinunternehmer: false,
|
||||
impressumUrl: 'https://haarlinie.example/impressum',
|
||||
datenschutzUrl: 'https://haarlinie.example/datenschutz',
|
||||
bundesland: 'RP',
|
||||
}
|
||||
const PROJEKT = { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.example', subdomain: 'haarlinie' }
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const UPDATED_AT = '2026-10-03T08:15:00.000Z'
|
||||
|
||||
const einstellung = () => {
|
||||
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 <b>x</b> \uD83D${'x'.repeat(8)}😀`
|
||||
const projekt = { projectName: `Salon \uD83D <i>x</i>`, 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('<b>x</b>') && text.includes('<i>x</i>'), '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')
|
||||
})
|
||||
Reference in new issue
Block a user