Terminplaner: Kunden-Schnittstelle

/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen
(Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der
Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei
Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen,
kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung
nimmt eine Funktion unter der Sperre.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 9e8fd33713
commit e3e198fb1a
10 files changed
+1766 -40

No files matched your search

+124 -13
View File
@@ -10,6 +10,8 @@ import {
preisText,
preisHinweis,
zahlungText,
fehlendePflichtangaben,
zeigtWiderruf,
} from '../server/services/termine/einstellungen.js'
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
@@ -22,9 +24,12 @@ const R2 = 'r_aaaaaaa2'
const S1 = 's_bbbbbbb1'
const S2 = 's_bbbbbbb2'
/** Pflichtangaben fuer Gaeste (R13/R13a): Telefon immer, Anschrift bei Widerrufsbelehrung. */
const PFLICHT = { telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler' }
/** Kleinste gueltige Eingabe; alles andere kommt aus den Vorgaben. */
const basis = () => ({
betrieb: { name: 'Salon Müller', email: 'info@salon.de' },
betrieb: { name: 'Salon Müller', email: 'info@salon.de', ...PFLICHT },
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']] } }],
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }],
})
@@ -142,6 +147,29 @@ test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mi
})
})
test('Vorlagen sind Entwuerfe: ohne Telefon und Anschrift gueltig, fehlendePflichtangaben nennt, was noch fehlt (R41)', () => {
const ohne = { name: 'Salon Müller', email: 'info@salon.de' }
const salon = ausVorlage('salon', ohne, NOW)
assert.equal(salon.betrieb.telefon, '')
assert.equal(salon.betrieb.adresse, '')
assert.deepEqual(fehlendePflichtangaben(salon), ['betrieb.telefon', 'betrieb.adresse'], 'Verbraucher + sofort: Telefon und Anschrift')
// Anfrage-Betriebe zeigen keine Widerrufsbelehrung: nur das Telefon fehlt
assert.deepEqual(fehlendePflichtangaben(ausVorlage('handwerk', ohne, NOW)), ['betrieb.telefon'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', { ...ohne, telefon: '06301 1234' }, NOW)), ['betrieb.adresse'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', BETRIEB, NOW)), [])
// der Entwurf selbst besteht die strenge Pruefung (wie PATCH sie macht) erst mit den Angaben
const streng = pruefe(salon, { bisher: salon })
assert.deepEqual(Object.keys(streng.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
assert.ok(pruefe({ ...salon, betrieb: { ...salon.betrieb, ...PFLICHT } }, { bisher: salon }).einstellung, 'mit den Angaben gueltig')
// Name und E-Mail bleiben Pflicht, auch im Entwurf
assert.throws(() => ausVorlage('salon', { telefon: '06301 1234' }, NOW), (err) => {
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
return true
})
})
test('Vorlagen: VORLAGEN ist eingefroren', () => {
assert.throws(() => { VORLAGEN.salon.leistungen.push({}) })
assert.throws(() => { VORLAGEN.neu = {} })
@@ -154,7 +182,7 @@ test('Vorgaben werden aufgefuellt', () => {
assert.equal(e.schema, 1)
assert.equal(e.vorlage, null)
assert.deepEqual(e.betrieb, {
name: 'Salon Müller', email: 'info@salon.de', telefon: '', kleinunternehmer: false, adresse: '',
name: 'Salon Müller', email: 'info@salon.de', telefon: '06301 1234', kleinunternehmer: false, adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: null, datenschutzUrl: null, agbUrl: null, websiteUrl: null,
})
assert.equal(e.anrede, 'sie')
@@ -199,26 +227,26 @@ test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => {
const f = fehlerVon({})
assert.ok(f['betrieb.name'])
assert.ok(f['betrieb.email'])
assert.ok(fehlerVon({ betrieb: { name: ' ', email: 'a@b.de' } })['betrieb.name'])
assert.ok(fehlerVon({ betrieb: { ...PFLICHT, name: ' ', email: 'a@b.de' } })['betrieb.name'])
assert.ok(fehlerVon({ betrieb: { name: 'X' } })['betrieb.email'])
})
test('Betriebsname wird auf 80 Zeichen gekuerzt, Steuerzeichen fliegen raus', () => {
const e = gueltig({ ...basis(), betrieb: { name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
const e = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
assert.ok(e.betrieb.name.startsWith('Salon Müller xxx'))
assert.equal([...e.betrieb.name].length, 80)
assert.ok(!/[\u0000-\u001f\u202E]/.test(e.betrieb.name))
})
test('E-Mail: genau ein Postfach, klein und getrimmt', () => {
assert.equal(gueltig({ ...basis(), betrieb: { name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
assert.equal(gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
for (const schlecht of ['a@b.de, c@d.de', 'a@b.de;c@d.de', '<a@b.de>', '"a"@b.de', 'a b@c.de', 'ohne-at.de', 'a@b', 'a@@b.de', '@b.de', 'a@.de', `${'x'.repeat(250)}@b.de`]) {
assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: schlecht } })['betrieb.email'], schlecht)
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: schlecht } })['betrieb.email'], schlecht)
}
})
test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeichen', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } })
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
const f = fehlerVon(mit('impressumUrl', 'javascript:alert(1)'))
assert.ok(f['betrieb.impressumUrl'])
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
@@ -232,7 +260,7 @@ test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeic
})
test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } })
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
for (const schlecht of [
'https://exa"mple.com/', "https://a'b.de/", 'https://a`b.de/', "https://a'b.de/x'y", 'https://a<b.de/', 'https://a>b.de/',
@@ -244,7 +272,7 @@ test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtic
})
test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => {
const url = (wert) => gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
const url = (wert) => gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
assert.equal(url("https://ab.de/x'y"), 'https://ab.de/x%27y')
assert.equal(url("https://ab.de/?q='a'#f'g"), 'https://ab.de/?q=%27a%27#f%27g')
assert.equal(url('https://Salon.DE/Impressum'), 'https://salon.de/Impressum')
@@ -256,12 +284,12 @@ test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycod
}
// die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ')
const apostrophe = "'".repeat(110)
assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
assert.equal(url(`https://ab.de/${"'".repeat(80)}`).length, 'https://ab.de/'.length + 80 * 3)
})
test('Unsichtbare Formatzeichen: Name nur daraus ist leer (Pflichtfeld), sonst werden sie entfernt', () => {
const name = (wert) => ({ ...basis(), betrieb: { name: wert, email: 'a@b.de' } })
const name = (wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: wert, email: 'a@b.de' } })
for (const leer of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u200B\u200E \uFEFF', ' \u200B\u200D\u200C ', '\u200D', '\u200C\u200D']) {
assert.ok(fehlerVon(name(leer))['betrieb.name'], JSON.stringify(leer))
}
@@ -288,7 +316,7 @@ test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail
ok.ressourcen[0].name = 'An\u200Bna'
ok.leistungen[0].beschreibung = 'Mit\u200E\u2060 Waschen'
ok.storno = { text: 'Zeile\u200B 1\n\u200EZeile 2' }
ok.betrieb = { name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
ok.betrieb = { ...PFLICHT, name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
const e2 = gueltig(ok)
assert.equal(e2.ressourcen[0].name, 'Anna')
assert.equal(e2.leistungen[0].beschreibung, 'Mit Waschen')
@@ -791,6 +819,89 @@ test('preisText, preisHinweis, zahlungText', () => {
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: [] } }), 'Bezahlung vor Ort')
})
test('R13: Telefon ist immer Pflicht (Pfad betrieb.telefon), auch ohne Widerrufsbelehrung; Leerraum und Steuerzeichen zaehlen als leer', () => {
const unternehmer = { ...basis(), recht: { gaesteSind: 'unternehmer' }, betrieb: { name: 'X', email: 'a@b.de', adresse: PFLICHT.adresse } }
assert.deepEqual(Object.keys(fehlerVon(unternehmer)), ['betrieb.telefon'])
for (const leer of [undefined, null, '', ' ', '\t\n', '\u200b\u200b', 42, {}]) {
const f = fehlerVon({ ...basis(), betrieb: { ...basis().betrieb, telefon: leer } })
assert.deepEqual(Object.keys(f), ['betrieb.telefon'], String(leer))
assert.match(f['betrieb.telefon'], /Telefonnummer/)
}
assert.equal(gueltig({ ...unternehmer, betrieb: { ...unternehmer.betrieb, telefon: ' 0171 2345678 ' } }).betrieb.telefon, '0171 2345678')
})
test('R13a: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbelehrung erscheint: Verbraucher + sichtbare sofort-Leistung', () => {
const ohneAdresse = (zusatz = {}) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', telefon: PFLICHT.telefon }, ...zusatz })
const f = fehlerVon(ohneAdresse())
assert.deepEqual(Object.keys(f), ['betrieb.adresse'])
assert.match(f['betrieb.adresse'], /Anschrift/)
for (const leer of ['', ' ', '\u200b']) {
assert.deepEqual(Object.keys(fehlerVon({ ...ohneAdresse(), betrieb: { ...ohneAdresse().betrieb, adresse: leer } })), ['betrieb.adresse'])
}
// gemischte Gaeste sind auch Verbraucher
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse({ recht: { gaesteSind: 'gemischt' } }))), ['betrieb.adresse'])
// ohne Widerrufsbelehrung keine Anschrift noetig: nur Unternehmer, Freizeitleistung, nur Anfragen
assert.equal(gueltig(ohneAdresse({ recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
assert.equal(gueltig(ohneAdresse({ recht: { freizeit: true } })).betrieb.adresse, '')
const anfragen = { leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }
assert.equal(gueltig(ohneAdresse(anfragen)).betrieb.adresse, '')
// eine sofort-Leistung, die Gaeste nicht sehen (inaktiv oder nur an inaktiver Ressource), loest nichts aus
const ruht = {
ressourcen: [{ id: R1, name: 'Anna', plan: {} }, { id: R2, name: 'Ben', aktiv: false, plan: {} }],
leistungen: [
{ id: S1, name: 'Anfrage', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' },
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], buchungsart: 'sofort', aktiv: false },
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], buchungsart: 'sofort', aktiv: false },
],
}
assert.equal(gueltig(ohneAdresse(ruht)).betrieb.adresse, '')
// mit einer sichtbaren sofort-Leistung daneben wieder Pflicht
const mitSofort = { ...ruht, leistungen: [...ruht.leistungen, { id: 's_bbbbbbb4', name: 'Sofort', dauer: 30, ressourcen: [R1], buchungsart: 'sofort' }] }
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(mitSofort))), ['betrieb.adresse'])
})
test('R13/R13a: beide Pflichtangaben fehlen => beide Fehler; Reihenfolge der Pfade Telefon, Anschrift; andere Fehler bleiben daneben', () => {
const f = fehlerVon({ ...basis(), betrieb: { name: '', email: 'a@b.de' } })
assert.deepEqual(Object.keys(f), ['betrieb.name', 'betrieb.telefon', 'betrieb.adresse'])
})
test('R13/R13a: fehlendePflichtangaben stimmt mit oeffentlicheEinstellung.widerruf ueberein (alle Kombinationen)', () => {
for (const gaesteSind of ['verbraucher', 'unternehmer', 'gemischt']) {
for (const freizeit of [false, true]) {
for (const buchungsart of ['sofort', 'anfrage']) {
for (const aktiv of [true, false]) {
const e = gueltig({
...basis(),
recht: { gaesteSind, freizeit },
leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart, aktiv }],
})
const name = JSON.stringify({ gaesteSind, freizeit, buchungsart, aktiv })
const widerruf = oeffentlicheEinstellung(e, NOW).widerruf
assert.equal(zeigtWiderruf(e), widerruf, name)
const ohne = { ...e, betrieb: { ...e.betrieb, adresse: '', telefon: '' } }
assert.deepEqual(fehlendePflichtangaben(ohne), widerruf ? ['betrieb.telefon', 'betrieb.adresse'] : ['betrieb.telefon'], name)
assert.deepEqual(fehlendePflichtangaben(e), [], name)
}
}
}
}
})
test('R41: entwurf laesst nur die beiden Pflichtangaben aus; alle anderen Pruefungen gelten weiter; ohne entwurf strikt', () => {
const roh = { ...basis(), betrieb: { name: 'Salon Müller', email: 'info@salon.de' } }
assert.deepEqual(Object.keys(fehlerVon(roh)), ['betrieb.telefon', 'betrieb.adresse'], 'Vorgabe: strikt')
assert.deepEqual(Object.keys(fehlerVon(roh, { entwurf: false })), ['betrieb.telefon', 'betrieb.adresse'])
const e = gueltig(roh, { entwurf: true })
assert.equal(e.betrieb.telefon, '')
assert.equal(e.betrieb.adresse, '')
const kaputt = { betrieb: { name: '', email: 'kein-postfach', impressumUrl: 'javascript:alert(1)' }, ressourcen: [], leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1] }] }
const f = fehlerVon(kaputt, { entwurf: true })
assert.deepEqual(Object.keys(f).sort(), ['betrieb.email', 'betrieb.impressumUrl', 'betrieb.name', 'leistungen.0.ressourcen'])
assert.ok(!('betrieb.telefon' in f) && !('betrieb.adresse' in f))
})
// ---------------------------------------------------------------- Oeffentliche Sicht
test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und interne Angaben', () => {
@@ -871,7 +982,7 @@ test('oeffentlicheEinstellung: storno null ohne Text, widerruf nur bei sofort +
const freizeit = gueltig({ ...basis(), recht: { freizeit: true } })
assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false)
const klein = gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', kleinunternehmer: true } })
const klein = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', kleinunternehmer: true } })
assert.equal(oeffentlicheEinstellung(klein, NOW).preisHinweis, 'kein Ausweis der USt nach § 19 UStG')
})