Files
Webklar-Kundenbereich/server/services/termine/fragebogen.js
T
ClaudeandClaude Sonnet 5.5 008ad4fb1e Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung
fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h),
je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false),
SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10
Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert
sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc;
der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate.

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

315 lines
16 KiB
JavaScript

import { config } from '../../config.js'
import { aboMeldung } from '../featureOrder.js'
import { featureHaken, oeffentlicheAnsicht } from '../featureRegistry.js'
import { MELDUNG_PFLICHT, fehlendePflichtangaben, hatPreis, sichtbareLeistungen, text } from './einstellungen.js'
import { vorlagenListe } from './vorlagen.js'
/**
* Bestell-Fragebogen des Terminplaners (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md): Der Kunde
* richtet den Terminplaner vor der Bestellung ein; der Entwurf steht in termineConfig.einstellung, der Fragebogen
* (Branche, bestaetigte Schritte, Wuensche zum Einbau) im Attribut termineConfig.fragebogen als JSON-Text.
* Teil 1: Schritte, Branchen, Grundform und Normalisierung. Teil 2: Bestell-Gate (bestellpruefung), weiche
* Gesundheits-Stichwortpruefung und die Sicht der Bestellseite (bestellungAnsicht). Ohne Appwrite und ohne Uhr
* (bestellungAnsicht liest nur das Register und den Haken belehrung).
* Fehler sind { [pfad]: text } wie bei normalisiereEinstellung (Pfad '_' = das Ganze); Texte in Du-Form.
*/
/** Die acht Schritte des Fragebogens in fester Reihenfolge; `bestaetigt` nennt die vom Kunden abgehakten. */
export const SCHRITTE = Object.freeze(['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'])
/**
* Branchen zur Auswahl: alle Vorlagen in der Reihenfolge von vorlagenListe(), danach der Gesundheitsbereich
* (ohne Vorlage; er fuehrt in den Hinweis- und Pruefweg fuer Gesundheitsangaben). Schluessel und Name der letzten
* Branche gelten woertlich fuer Bestellpruefung, Oberflaeche, Einbauanleitung und Ticket.
*/
export const BRANCHEN = Object.freeze([
...vorlagenListe().map(({ key, name }) => Object.freeze({ key, name, vorlage: key, gesundheit: false })),
Object.freeze({ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }),
])
/** Obergrenze fuer den JSON-Text des Fragebogens (Zeichen); die Spalte in Appwrite ist mit 20000 groesser. */
export const FRAGEBOGEN_MAX = 16000
const EINBAU_MODI = ['inline', 'knopf']
const TEXTGRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Ein leerer Fragebogen; bei jedem Aufruf ein neues Objekt. */
export function leerFragebogen() {
return {
schema: 1,
branche: null,
bestaetigt: [],
bundeslandGewaehlt: false,
preiseGeprueft: false,
einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' },
}
}
function wahrheit(fehl, pfad, wert) {
if (wert === undefined || wert === null) return false
if (typeof wert !== 'boolean') {
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
return false
}
return wert
}
function einbauFelder(fehl, eingabe) {
const quelle = eingabe === undefined || eingabe === null ? {} : eingabe
if (!istObjekt(quelle)) {
fehl('einbau', 'Die Angaben zum Einbau sind ungültig.')
return leerFragebogen().einbau
}
const einbau = { modus: null }
if (quelle.modus !== undefined && quelle.modus !== null) {
if (EINBAU_MODI.includes(quelle.modus)) einbau.modus = quelle.modus
else fehl('einbau.modus', 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.')
}
for (const [feld, max] of Object.entries(TEXTGRENZEN)) {
const wert = quelle[feld]
if (wert === undefined || wert === null) einbau[feld] = ''
else if (typeof wert !== 'string') {
fehl(`einbau.${feld}`, 'Der Text ist ungültig.')
einbau[feld] = ''
} else einbau[feld] = text(wert, max, { mehrzeilig: MEHRZEILIG.has(feld) })
}
return einbau
}
/**
* Prueft und bereinigt einen Fragebogen aus Kundeneingabe oder gespeichertem JSON.
* Baut ein neues Objekt Feld fuer Feld (unbekannte Schluessel und __proto__ fallen weg), kuerzt Texte, entfernt
* Steuerzeichen und ordnet `bestaetigt` nach SCHRITTE ohne Doppelte und Fremde. Fehlende Felder sind leer/false.
* Ergebnis: { fragebogen } oder { fehler: { [pfad]: text } }; idempotent.
*/
export function normalisiereFragebogen(eingabe) {
if (!istObjekt(eingabe)) return { fehler: { _: 'Der Fragebogen ist ungültig.' } }
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
let branche = null
if (eingabe.branche !== undefined && eingabe.branche !== null) {
if (BRANCHEN.some((b) => b.key === eingabe.branche)) branche = eingabe.branche
else fehl('branche', 'Bitte wähle eine Branche aus der Liste.')
}
let bestaetigt = []
if (eingabe.bestaetigt !== undefined && eingabe.bestaetigt !== null) {
if (Array.isArray(eingabe.bestaetigt)) bestaetigt = SCHRITTE.filter((schritt) => eingabe.bestaetigt.includes(schritt))
else fehl('bestaetigt', 'Die bestätigten Schritte sind ungültig.')
}
const fragebogen = {
schema: 1,
branche,
bestaetigt,
bundeslandGewaehlt: wahrheit(fehl, 'bundeslandGewaehlt', eingabe.bundeslandGewaehlt),
preiseGeprueft: wahrheit(fehl, 'preiseGeprueft', eingabe.preiseGeprueft),
einbau: einbauFelder(fehl, eingabe.einbau),
}
return Object.keys(fehler).length ? { fehler } : { fragebogen }
}
// ---------------------------------------------------------------- Teil 2: Bestell-Gate
/**
* Titel der Schritte (und der Bestellseite) fuer Meldungen und Oberflaeche; Schluessel wie SCHRITTE plus `bestellen`
* ("Prüfen und bestellen"). Der Titel steht in "Bitte bestätige den Schritt „…“." und wird dort woertlich gezeigt.
*/
export const SCHRITT_TITEL = Object.freeze({
branche: 'Branche und Betrieb',
wer: 'Wer wird gebucht',
leistungen: 'Leistungen',
zeiten: 'Zeiten',
regeln: 'Regeln',
gastfelder: 'Gastfelder',
benachrichtigung: 'Benachrichtigung',
website: 'Website',
bestellen: 'Prüfen und bestellen',
})
// Schritt, in dem der Kunde eine fehlende Pflichtangabe nachtraegt (Name und Anschrift stehen in Schritt 1, Telefon in Schritt 8)
const PFLICHT_SCHRITT = { 'betrieb.telefon': 'website', 'betrieb.adresse': 'branche' }
const SCHRITT_FOLGE = [...SCHRITTE, 'bestellen']
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
/**
* Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. Jeder Punkt ist
* ein eigener Eintrag; sortiert nach Schrittfolge (SCHRITTE, dann `bestellen`), innerhalb eines Schritts in der
* Reihenfolge der Kriterien (a) bis (h):
* (a) fehlende Pflichtangaben (Telefon ⇒ website, Anschrift ⇒ branche; Text aus MELDUNG_PFLICHT),
* (b) keine fuer Gaeste sichtbare Leistung ⇒ leistungen, (c) je unbestaetigter Schritt ein Punkt, dazu leistungen,
* wenn eine sichtbare Leistung einen Preis hat und die Preise nicht geprueft sind, (d) Bundesland nicht ausdruecklich
* gewaehlt ⇒ zeiten (der Serverstandard RP zaehlt nicht), (e) Einbau-Modus fehlt oder Knopf ohne Knopftext ⇒ website,
* (f) Branche fehlt oder Gesundheitsbereich ⇒ branche, (g) Impressum- oder Datenschutz-Adresse fehlt ⇒ website (je
* Adresse ein Punkt), (h) nur bei `mitAbo !== false`: das Abo laeuft nicht sauber ⇒ bestellen.
* `feature` = Register-Eintrag (nur label.name und aboMeldung); `abo` = Ergebnis von bestellbar(), gelesen nur bei
* mitAbo !== false: fehlt es (Lesefehler), gilt der Grund 'unbekannt' (im Zweifel nicht bestellbar). Der Checkout (B6)
* ruft mit `mitAbo: false` und ohne `abo`, dort liefert die Abo-Stufe (7) die Codes; nur GET /api/termine/einstellungen
* (C4a) uebergibt `abo`. Ohne Einstellung gibt es genau einen Punkt (branche). Rein: veraendert nichts, neues Array.
*/
export function bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true } = {}) {
if (!istObjekt(einstellung)) return [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]
const stand = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const einbau = istObjekt(stand.einbau) ? stand.einbau : {}
const offen = []
const punkt = (schritt, meldung) => offen.push({ schritt, text: meldung })
// (a) Pflichtangaben des Betriebs
for (const pfad of fehlendePflichtangaben(einstellung)) punkt(PFLICHT_SCHRITT[pfad], MELDUNG_PFLICHT[pfad])
// (b) mindestens eine Leistung, die Gaeste sehen
const sichtbar = sichtbareLeistungen(einstellung)
if (sichtbar.length === 0) {
punkt('leistungen', 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.')
}
// (c) jeder Schritt bestaetigt; Preise geprueft, wenn eine sichtbare Leistung einen hat
const bestaetigt = Array.isArray(stand.bestaetigt) ? stand.bestaetigt : []
for (const schritt of SCHRITTE) {
if (!bestaetigt.includes(schritt)) punkt(schritt, `Bitte bestätige den Schritt „${SCHRITT_TITEL[schritt]}“.`)
}
if (sichtbar.some(hatPreis) && stand.preiseGeprueft !== true) {
punkt('leistungen', 'Bitte prüfe die Preise deiner Leistungen und bestätige sie. Die Preise der Vorlage sind nur Platzhalter.')
}
// (d) Bundesland ausdruecklich gewaehlt (der Serverstandard RP ist keine Wahl)
if (stand.bundeslandGewaehlt !== true) {
punkt('zeiten', 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.')
}
// (e) Einbau: Modus, bei einem Knopf auch dessen Text
if (!EINBAU_MODI.includes(einbau.modus)) {
punkt('website', 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.')
} else if (einbau.modus === 'knopf' && !istText(einbau.knopfText)) {
punkt('website', 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.')
}
// (f) Branche gewaehlt und nicht der Gesundheitsbereich
const branche = BRANCHEN.find((b) => b.key === stand.branche)
if (!branche) {
punkt('branche', 'Bitte wähle deine Branche.')
} else if (branche.gesundheit) {
const name = feature?.label?.name || 'Terminplaner'
punkt('branche', `Für Betriebe im Gesundheitsbereich ist „${name}“ nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.`)
}
// (g) Impressum und Datenschutzerklaerung (der Gaestehinweis verweist darauf)
if (!istText(betrieb.impressumUrl)) punkt('website', 'Bitte trage die Adresse deines Impressums ein. Gäste finden dort die Angaben zu deinem Betrieb.')
if (!istText(betrieb.datenschutzUrl)) punkt('website', 'Bitte trage die Adresse deiner Datenschutzerklärung ein. Der Hinweis für Gäste verweist darauf.')
// (h) Wartungs-Abo sauber laufend (nur wenn gefragt); fehlendes abo = Lesefehler = nicht bestellbar
if (mitAbo !== false && !(istObjekt(abo) && abo.ok === true)) {
punkt('bestellen', aboMeldung(feature, istObjekt(abo) ? abo.grund : 'unbekannt'))
}
// stabil nach Schrittfolge (Array.prototype.sort ist ab Node 12 stabil)
return offen.sort((a, b) => SCHRITT_FOLGE.indexOf(a.schritt) - SCHRITT_FOLGE.indexOf(b.schritt))
}
// ---------------------------------------------------------------- Gesundheits-Stichwortpruefung
// Regel: Treffer nur am Wortanfang (vor dem Treffer steht kein Buchstabe), Woerter = Folgen von Buchstaben.
// Gruppe 1: Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zaehlen: Physiotherapie, Arztpraxis).
const STAEMME_FREI = ['physio', 'therap', 'arzt', 'ärzt', 'zahnarzt', 'zahnärzt', 'heilpraktik', 'krankengymnast', 'osteopath', 'diagnos']
// Gruppe 2: Stamm plus hoechstens eine Beugungsendung, dann Wortende (Rezepte, Beschwerden, Behandlungen; nicht Rezeption, Rezeptur).
const STAEMME_BEUGUNG = ['rezept', 'beschwerde', 'behandlung']
const ENDUNGEN = new Set(['', 'e', 'en', 'n', 's', 'es'])
const MAX_WARNUNGEN = 10
const MAX_WORT = 60
const MAX_NAME = 40
const istGesundheitswort = (wort) =>
STAEMME_FREI.some((stamm) => wort.startsWith(stamm))
|| STAEMME_BEUGUNG.some((stamm) => wort.startsWith(stamm) && ENDUNGEN.has(wort.slice(stamm.length)))
/** Woerter eines Textes (Folgen von Buchstaben, NFC), die ein Gesundheitsstichwort sind, in Textreihenfolge. */
function gesundheitsWoerter(wert) {
if (typeof wert !== 'string') return []
return [...wert.normalize('NFC').matchAll(/[\p{L}\p{M}]+/gu)]
.map((m) => m[0])
.filter((wort) => istGesundheitswort(wort.toLowerCase()))
}
const kurz = (wert, max) => (wert.length > max ? `${wert.slice(0, max - 1)}…` : wert)
/**
* Weiche Pruefung auf Gesundheitsangaben (Spec 3.5, D6): Stichwortliste ueber Betriebsname, Leistungsnamen,
* Beschreibungen sowie Beschriftungen und Auswahlen der Zusatzfelder. Ergebnis: hoechstens 10 Meldungen (Klartext, ohne
* Doppelte), jede nennt den Fundort und das gefundene Wort. Nur Warnblock fuers Team (T16, Ticket): dem Kunden zeigt
* Block C nichts, eine Meldung ist kein Fehler. Liest nie Gaestedaten (nur die Einstellung des Betriebs).
* @param {object|null} einstellung
* @returns {string[]}
*/
export function gesundheitsWarnungen(einstellung) {
if (!istObjekt(einstellung)) return []
const meldungen = new Set()
const pruefe = (wert, fundort) => {
for (const wort of gesundheitsWoerter(wert)) {
if (meldungen.size >= MAX_WARNUNGEN) return
meldungen.add(`${fundort} enthält „${kurz(wort, MAX_WORT)}“`)
}
}
pruefe(einstellung.betrieb?.name, 'Betriebsname')
for (const leistung of Array.isArray(einstellung.leistungen) ? einstellung.leistungen : []) {
if (!istObjekt(leistung)) continue
pruefe(leistung.name, 'Leistungsname')
const name = typeof leistung.name === 'string' ? kurz(leistung.name.trim(), MAX_NAME) : ''
pruefe(leistung.beschreibung, name ? `Beschreibung der Leistung „${name}“` : 'Beschreibung einer Leistung')
}
const zusatz = einstellung.gastFelder?.zusatz
for (const feld of Array.isArray(zusatz) ? zusatz : []) {
if (!istObjekt(feld)) continue
pruefe(feld.label, 'Zusatzfeld-Beschriftung')
const label = typeof feld.label === 'string' ? kurz(feld.label.trim(), MAX_NAME) : ''
for (const option of Array.isArray(feld.optionen) ? feld.optionen : []) {
pruefe(option, label ? `Auswahl im Zusatzfeld „${label}“` : 'Auswahl in einem Zusatzfeld')
}
}
return [...meldungen]
}
// ---------------------------------------------------------------- Sicht der Bestellseite
// Titel der Rechtsunterlagen (Registerfelder) auf der Bestellseite
const DOKUMENT_TITEL = [
['leistungsbeschreibung', 'Leistungsbeschreibung'],
['avv', 'Auftragsverarbeitungsvertrag (AVV)'],
['widerruf', 'Widerrufsbelehrung'],
]
/**
* Sicht der Bestellseite ("Prüfen und bestellen"): reine Abbildung von oeffentlicheAnsicht(feature.key) (B1) plus die
* Belehrung. `consent` bleibt unveraendert, inklusive `knopf` und des `haken`-Flags je Eintrag (der Knopf zeigt genau diesen
* Text, B speichert genau diesen Text). `dokumente` stammen aus den Registerfeldern leistungsbeschreibung/avv/widerruf und
* nur mit gesetzter `url`. `belehrung` ist das Ergebnis des Hakens belehrung (derselbe Text, den B7 im Nachweis speichert;
* ohne Haken null). Keine internen Felder (ticket, mails). Unbekannte Funktion ⇒ null.
* @param {{ key: string }} feature Register-Eintrag
* @returns {Promise<{ preis: string, preisHinweis: string, einrichtung: string, consent: object, dokumente: object[], belehrung: string|null }|null>}
*/
export async function bestellungAnsicht(feature) {
const ansicht = typeof feature?.key === 'string' ? oeffentlicheAnsicht(feature.key) : null
if (!ansicht) return null
const dokumente = []
for (const [id, titel] of DOKUMENT_TITEL) {
const fassung = ansicht[id]
if (fassung && typeof fassung.url === 'string' && fassung.url !== '') dokumente.push({ id, titel, version: fassung.version, url: fassung.url })
}
const belehrung = (await featureHaken(feature.key).belehrung?.()) ?? null
return {
preis: ansicht.priceLabel,
preisHinweis: config.ustHinweis,
einrichtung: ansicht.setupEstimate,
consent: ansicht.consent,
dokumente,
belehrung,
}
}