From e3e198fb1a2fc184a1c61d48708d1cb5e79c80f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:30:32 +0000 Subject: [PATCH] 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 --- server/index.js | 2 + server/routes/termine.js | 474 +++++++++++ server/services/termine/einstellungen.js | 45 +- server/services/termine/kontext.js | 28 +- server/services/termine/vorlagen.js | 9 +- test/kundenansichtGuard.test.js | 1 + test/publicTermine.test.js | 24 +- test/termineEinstellungen.test.js | 137 +++- test/termineKontext.test.js | 125 ++- test/termineRoutes.test.js | 961 +++++++++++++++++++++++ 10 files changed, 1766 insertions(+), 40 deletions(-) create mode 100644 server/routes/termine.js create mode 100644 test/termineRoutes.test.js diff --git a/server/index.js b/server/index.js index 2cd3390..f1582fc 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import customerDataRoutes from './routes/customerData.js' import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' +import termineRoutes from './routes/termine.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' @@ -77,6 +78,7 @@ app.use('/api/chat', chatRoutes) app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) +app.use('/api/termine', termineRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) diff --git a/server/routes/termine.js b/server/routes/termine.js new file mode 100644 index 0000000..7313edd --- /dev/null +++ b/server/routes/termine.js @@ -0,0 +1,474 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { analysePrivacyData } from '../services/analysePrivacy.js' +import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' +import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' +import { imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' +import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' +import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js' +import { zustellFehler } from '../services/termine/outbox.js' +import { termineFuerKunde } from '../services/termine/schalter.js' +import { belegungAus, freieZeiten } from '../services/termine/slots.js' +import { buchungsNummer } from '../services/termine/token.js' +import { VORLAGEN, ausVorlage, vorlagenListe } from '../services/termine/vorlagen.js' +import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine/zeit.js' + +/** + * Kunden-Schnittstelle des Terminplaners: /api/termine (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 9). + * + * Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder + * `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409. + * Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in + * services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion, + * termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail). + * + * Fehler: { error: , fehler?: { : Text }, code? }. `error` liest das Portal (wie bei allen Routen), + * `fehler` zeigt Feldfehler am Feld (Pfade wie 'betrieb.telefon' oder 'leistungen.s_abcd1234'). Texte von Appwrite oder + * unbekannten Fehlern verlassen den Server nie (500 "Interner Serverfehler"). + * Antworten sind nie zwischenspeicherbar (Gastdaten, Kalender-Links). + */ + +const router = Router() + +const HOUR_MS = 60 * 60 * 1000 +const MAX_WRITES_PER_HOUR = 120 +const MAX_TAGE = 62 +const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen'] +const STATUS_TEXT = { + gehalten: 'Reserviert', + angefragt: 'Angefragt', + bestaetigt: 'Bestätigt', + erledigt: 'Erledigt', + nichterschienen: 'Nicht erschienen', + abgesagt: 'Abgesagt', + abgelehnt: 'Abgelehnt', + verfallen: 'Verfallen', +} +const CSV_KOPF = ['Nummer', 'Status', 'Datum', 'Uhrzeit', 'Leistung', 'Person/Platz', 'Name', 'E-Mail', 'Telefon', 'Nachricht'] +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + fremd: 'Kein Zugriff auf dieses Projekt.', + nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.', + nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.', + abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.', + keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.', + zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', + vorlage: 'Bitte wähle eine Vorlage aus der Liste.', + vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.', + version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.', + art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.', + zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).', + zeitraumLang: `Der Zeitraum darf höchstens ${MAX_TAGE} Tage umfassen.`, + zeitraumUmgekehrt: 'Das Ende darf nicht vor dem Beginn liegen.', + status: 'Ungültiger Status.', + tag: 'Bitte ein Datum auswählen.', + leistung: 'Bitte eine Leistung auswählen.', + ressource: 'Unbekannte Person oder unbekannter Platz.', + keineRessource: 'Diese Person oder diesen Platz gibt es nicht.', + ungueltig: 'Ungültige Eingabe.', +} + +// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +// ---------------------------------------------------------------- Fehler + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */ +const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) }) + +/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 500 ohne Rohtext. */ +function fail(res, err) { + const status = Number.isInteger(err?.status) ? err.status : 500 + const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {} + const eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET' + if (!eigen || status < 400 || status >= 500) { + console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return res.status(500).json({ error: 'Interner Serverfehler' }) + } + const body = { error: Object.values(texte)[0] ?? String(err.message) } + if (Object.keys(texte).length > 0) body.fehler = texte + if (typeof err.code === 'string') body.code = err.code + return res.status(status).json(body) +} + +/** Handler mit Fehlerbehandlung (Express 4 fängt async-Fehler nicht selbst). */ +const route = (fn) => async (req, res, next) => { + try { + await fn(req, res, next) + } catch (err) { + fail(res, err) + } +} + +// ---------------------------------------------------------------- Schreib-Limit + +// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE +const recentWrites = new Map() +function writeRateLimited(customerId) { + const now = uhr().getTime() + for (const [key, times] of recentWrites) { + const live = times.filter((t) => now - t < HOUR_MS) + if (live.length === 0) recentWrites.delete(key) + else if (live.length !== times.length) recentWrites.set(key, live) + } + const list = recentWrites.get(customerId) || [] + list.push(now) + recentWrites.set(customerId, list) + return list.length > MAX_WRITES_PER_HOUR +} + +const limitWrites = route((req, res, next) => { + if (writeRateLimited(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuViele }) + return next() +}) + +export function resetTermineRoutesForTests() { + recentWrites.clear() + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Projekt und Funktion + +const projectIdOf = (req) => { + const raw = req.query.projectId ?? req.body?.projectId + return typeof raw === 'string' ? raw : '' +} + +/** + * Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active. + * Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409). + */ +const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => { + const projectId = projectIdOf(req) + if (!projectId) throw stop(400, MELDUNG.projekt, undefined, 'projectId') + const customerId = getSessionCustomerId(req) + try { + req.project = await assertOwnProject(customerId, projectId) + } catch (err) { + // auch eine unbrauchbare ID (Appwrite 400) ist kein eigenes Projekt + throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err + } + req.projectId = projectId + if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + const feature = await getFeatureDoc(customerId, projectId, 'termine') + const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null + if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv') + if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet') + req.ctx = ctx + return next() +}) + +/** Wer ändert? Für updatedBy der Einstellung (Kundenansicht: der Mitarbeiter, sonst der Kunde). */ +const von = (req) => (isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : `kunde:${getSessionCustomerId(req)}`) + +/** Buchungen, Zeiten und Aktionen brauchen gespeicherte Einstellungen (Betriebsangaben). */ +function mitEinstellung(req) { + if (!req.ctx.einstellung) throw stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') + return req.ctx +} + +// ---------------------------------------------------------------- Einstellungen + +/** Einstellung für den Browser: ohne Feed-Token (die Links stehen in `feeds`, neue Token vergibt nur der Server). */ +const fuerKunde = (e) => ({ ...e, ressourcen: (e.ressourcen || []).map(({ feedToken: _geheim, ...rest }) => rest) }) + +const feedUrl = (siteKey, token) => `${config.publicBaseUrl}/api/public/termine/v1/kalender/${siteKey}/${token}.ics` + +/** { [ressourceId]: Kalender-Link } aller Personen und Plätze. */ +function feedLinks(ctx) { + const links = {} + for (const r of ctx.einstellung?.ressourcen || []) { + if (typeof r?.id === 'string' && typeof r.feedToken === 'string') links[r.id] = feedUrl(ctx.feature.siteKey, r.feedToken) + } + return links +} + +const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) + +/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */ +function einbauSnippet(ctx) { + const telefon = ctx.einstellung?.betrieb?.telefon + const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' + return [ + `
`, + `

${ausweich}

`, + '
', + ``, + ].join('\n') +} + +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => { + const { ctx } = req + const e = ctx.einstellung + res.json({ + state: ctx.feature.state, + aktivSeit: ctx.feature.activatedAt || null, + einstellung: e ? fuerKunde(e) : null, + version: ctx.version, + vorlagen: vorlagenListe(), + feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {}, + siteKey: ctx.feature.siteKey || null, + einbau: { snippet: einbauSnippet(ctx) }, + anfragenOffen: await zaehleAnfragen(ctx.feature.$id), + fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar + }) +})) + +/** Betriebsangaben aus dem Kundenkonto für eine Vorlage: Name/Firma, Anschrift, E-Mail (analysePrivacyData) und Telefon. */ +async function betriebAusKonto(customerId) { + let kunde = null + try { + kunde = await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err?.status !== 404) throw err + } + const daten = analysePrivacyData(kunde) + return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' } +} + +router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const key = req.body?.vorlage + if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage') + if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') + + const betrieb = await betriebAusKonto(getSessionCustomerId(req)) + const now = uhr() + let gespeichert + try { + // version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen + gespeichert = await speichereEinstellung(req.ctx.feature, () => { + try { + return ausVorlage(key, betrieb, now) + } catch (err) { + throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } }) + } + }, { version: 0, by: von(req), now }) + } catch (err) { + throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err + } + await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`) + res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) }) +})) + +router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const { version, einstellung: eingabe } = req.body ?? {} + if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version') + const now = uhr() + const feature = req.ctx.feature + + // Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40) + const gespeichert = await speichereEinstellung(feature, async (bisher) => { + const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null + const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now }) + if (ergebnis.fehler) { + throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler }) + } + return ergebnis.einstellung + }, { version, by: von(req), now }) + + await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert') + res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version }) +})) + +router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const ressourceId = String(req.params.id) + const now = uhr() + // Baut auf dem aktuellen Stand auf (version null) und ersetzt nur das Token dieser einen Person/dieses Platzes + const gespeichert = await speichereEinstellung(req.ctx.feature, (bisher) => { + const ressourcen = Array.isArray(bisher?.ressourcen) ? bisher.ressourcen : [] + if (!ressourcen.some((r) => r?.id === ressourceId)) throw stop(404, MELDUNG.keineRessource, 'unbekannt') + return { ...bisher, ressourcen: ressourcen.map((r) => (r.id === ressourceId ? { ...r, feedToken: neuerFeedToken() } : r)) } + }, { version: null, by: von(req), now }) + + const token = gespeichert.einstellung.ressourcen.find((r) => r.id === ressourceId).feedToken + await logIfKundenansicht(req, 'termin_einstellungen', 'Kalender-Link erneuert') + // version: die Änderung zählt die Version hoch, die offene Einstellungsseite braucht die neue für ihr nächstes Speichern + res.json({ url: feedUrl(req.ctx.feature.siteKey, token), version: gespeichert.version }) +})) + +// ---------------------------------------------------------------- Buchungen lesen + +const iso = (wert) => { + const zeit = wert ? new Date(wert) : null + return zeit && !Number.isNaN(zeit.getTime()) ? zeit.toISOString() : null +} + +/** Eine Buchung für das Portal. datum = Berliner Tag 'JJJJ-MM-TT', zeit = 'HH:MM' (Berliner Wanduhr). */ +function buchungJson(b, zustellFehlerIds) { + const start = new Date(b.startsAt) + const ende = new Date(b.endsAt) + const istSperre = b.quelle === 'sperre' + const { tag, uhrzeit: zeit } = ausUtc(start) + const leistung = istSperre ? '' : b.leistungName || '' + return { + id: b.$id, + nummer: buchungsNummer(b.$id), + status: b.status, + quelle: b.quelle || 'online', + startsAt: start.toISOString(), + endsAt: ende.toISOString(), + datum: tag, + zeit, + leistungId: b.leistungId || '', + leistung, + dauer: Math.round((ende.getTime() - start.getTime()) / 60000), + titel: istSperre ? b.leistungName || 'Gesperrt' : b.gastName ? `${leistung}: ${b.gastName}` : leistung, + person: b.ressourceName || '', + ressourceId: b.ressourceId || '', + gastName: b.gastName || '', + gastEmail: b.gastEmail || '', + gastTelefon: b.gastTelefon || '', + gastNachricht: b.gastNachricht || '', + erinnerung: b.erinnerung === true, + zustellFehler: zustellFehlerIds.has(b.$id), + angenommenAt: iso(b.angenommenAt), + widerrufenAt: iso(b.widerrufenAt), + abgesagtVon: b.abgesagtVon || '', + absageGrund: b.absageGrund || '', + } +} + +const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null + +router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => { + const ctx = req.ctx + const { von: vonTag, bis: bisTag, status } = req.query + if (!tagGueltig(vonTag) || !tagGueltig(bisTag)) throw stop(400, MELDUNG.zeitraum, undefined, 'von') + if (bisTag < vonTag) throw stop(400, MELDUNG.zeitraumUmgekehrt, undefined, 'bis') + if (bisTag > plusTage(vonTag, MAX_TAGE)) throw stop(400, MELDUNG.zeitraumLang, undefined, 'bis') + + let gewollt = null + if (typeof status === 'string' && status !== '') { + gewollt = status.split(',').map((s) => s.trim()).filter(Boolean) + if (gewollt.length === 0 || gewollt.some((s) => !STATUS.includes(s))) throw stop(400, MELDUNG.status, undefined, 'status') + } else if (status !== undefined && typeof status !== 'string') { + throw stop(400, MELDUNG.status, undefined, 'status') + } + + const anfang = zuUtc(vonTag, '00:00') + const ende = zuUtc(bisTag, '24:00') + const liste = (await imZeitraum(ctx.feature.$id, anfang, ende)) + .filter((b) => new Date(b.endsAt).getTime() > anfang.getTime()) // Pause nach dem Termin zählt nicht zum Zeitraum + .filter((b) => gewollt === null || gewollt.includes(b.status)) + .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) + const mitMail = await zustellFehler(liste.filter((b) => b.quelle !== 'sperre').map((b) => b.$id)) + res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) }) +})) + +router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => { + const ctx = mitEinstellung(req) + const { leistung, ressource = 'egal', tag, ohne } = req.query + if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) { + throw stop(400, MELDUNG.leistung, undefined, 'leistung') + } + if (typeof ressource !== 'string' || (ressource !== 'egal' && !(ctx.einstellung.ressourcen || []).some((r) => r.id === ressource))) { + throw stop(400, MELDUNG.ressource, undefined, 'ressource') + } + if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag') + if (ohne !== undefined && (typeof ohne !== 'string' || !ID_RE.test(ohne))) throw stop(400, MELDUNG.ungueltig, undefined, 'ohne') + + const now = uhr() + const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00')) + // ohneRegeln: der Betrieb trägt auch kurzfristig und jenseits des Buchungszeitraums ein (nie in die Vergangenheit); + // ohne = die Buchung, die verschoben wird, belegt ihre neue Zeit nicht selbst + const zeiten = freieZeiten({ + einstellung: ctx.einstellung, + leistungId: leistung, + ressourceId: ressource, + tag, + belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}), + now, + ohneRegeln: true, + }) + res.json({ zeiten: zeiten.map((z) => z.uhrzeit) }) +})) + +// ---------------------------------------------------------------- Buchungen schreiben + +router.post('/buchungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const ctx = mitEinstellung(req) + const { projectId: _projekt, art, ...roh } = req.body ?? {} + if (art !== 'manuell' && art !== 'sperre') throw stop(400, MELDUNG.art, undefined, 'art') + const now = uhr() + const ergebnis = art === 'manuell' ? await manuell(ctx, roh, now) : await sperre(ctx, roh, now) + await logIfKundenansicht(req, 'termin_manuell', art) + res.status(201).json(ergebnis) +})) + +router.post('/buchungen/:id/aktion', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const ctx = mitEinstellung(req) + const { projectId: _projekt, ...roh } = req.body ?? {} + const id = String(req.params.id) + const ergebnis = await aktion(ctx, id, roh, uhr()) + // ein zweiter Klick ändert nichts und steht nicht im Protokoll + if (!ergebnis.schon) await logIfKundenansicht(req, 'termin_aktion', `${roh.aktion} ${buchungsNummer(id)}`) + res.json(ergebnis) +})) + +router.delete('/buchungen/:id', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { + const ctx = mitEinstellung(req) + const id = String(req.params.id) + const ergebnis = await entsperre(ctx, id) // nur Sperren; alles andere ist 404 + await logIfKundenansicht(req, 'termin_aktion', `entsperren ${buchungsNummer(id)}`) + res.json(ergebnis) +})) + +// ---------------------------------------------------------------- Export + +/** + * Eine Zelle der CSV-Datei: Anführungszeichen, Strichpunkt und Zeilenumbrüche werden gequotet; Zellen, die ein + * Tabellenprogramm als Formel lesen würde (= + - @ am Anfang, auch nach Leerraum, Tab oder CR; Tab und CR am Anfang), + * bekommen ein ' davor. + */ +function csvZelle(wert) { + let zelle = String(wert ?? '') + if (/^\s*[=+\-@]/.test(zelle) || /^[\t\r]/.test(zelle)) zelle = `'${zelle}` + return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle +} + +router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => { + const ctx = req.ctx + // alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren + const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z'))) + .filter((b) => b.quelle !== 'sperre') + .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) + const zeilen = [CSV_KOPF, ...liste.map((b) => { + const start = new Date(b.startsAt) + return [ + buchungsNummer(b.$id), + STATUS_TEXT[b.status] ?? b.status, + datumKurz(start), + uhrzeit(start), + b.leistungName, + b.ressourceName, + b.gastName, + b.gastEmail, + b.gastTelefon, + b.gastNachricht, + ] + })] + const csv = `${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n` + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="termine.csv"', + 'X-Content-Type-Options': 'nosniff', + }) + res.send(csv) +})) + +export default router diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 7c39544..1e28382 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -475,9 +475,12 @@ const MELDUNG_RESSOURCE = (r, n) => (r.typ === 'platz' * Prueft und normalisiert eine Einstellung (Eingabe vom Client oder aus einer Vorlage). * bisher = zuletzt gespeicherte Einstellung (Feed-Token, Storno-Fassung, Entfernen-Pruefung); * kuenftigeTermine = { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } } kuenftiger belegender Termine. + * Pflichtangaben fuer Gaeste (fehlendePflichtangaben): Telefon immer, Anschrift bei Widerrufsbelehrung. entwurf: true + * laesst nur diese beiden Pruefungen aus (Entwurf aus einer Vorlage, Kunde ohne Telefon/Anschrift im Konto); als + * oeffentlich buchbar gilt eine solche Einstellung erst, wenn die Angaben da sind (kontextPerSiteKey). * => { einstellung } oder { fehler: { [pfad]: text } }. */ -export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, now } = {}) { +export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, entwurf = false, now } = {}) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereEinstellung: now fehlt') const fehler = {} const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung } @@ -534,6 +537,10 @@ export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermi aufbewahrungTage: zahl(fehl, 'aufbewahrungTage', eingabe.aufbewahrungTage, { vorgabe: 90, min: 14, max: 365, meldung: 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.' }), } + if (!entwurf) { + for (const pfad of fehlendePflichtangaben(einstellung)) fehl(pfad, MELDUNG_PFLICHT[pfad]) + } + if (Object.keys(fehler).length === 0 && JSON.stringify(einstellung).length > GRENZEN.json) { fehl('_', 'Die Einstellungen sind zu umfangreich. Bitte entferne Zeitfenster oder Einträge.') } @@ -550,6 +557,40 @@ function farbe(fehl, wert) { return s } +// ---------------------------------------------------------------- Pflichtangaben und Widerruf + +const MELDUNG_PFLICHT = { + 'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.', + 'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.', +} + +/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */ +function sichtbareLeistungen(einstellung) { + const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id)) + return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id))) +} + +/** + * Zeigt die oeffentliche Sicht eine Widerrufsbelehrung (oeffentlicheEinstellung.widerruf)? Verbraucher-Gaeste und + * mindestens eine sichtbare Leistung, die sofort gebucht wird. + */ +export function zeigtWiderruf(einstellung) { + return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).some((l) => l.buchungsart === 'sofort') +} + +/** + * Pflichtangaben des Betriebs, die in einer Einstellung fehlen: 'betrieb.telefon' (immer noetig) und 'betrieb.adresse' + * (noetig, sobald die Widerrufsbelehrung erscheint, zeigtWiderruf). Reihenfolge Telefon, Anschrift; [] = vollstaendig. + * Ohne diese Angaben ist der Terminplaner fuer Gaeste nicht buchbar. + */ +export function fehlendePflichtangaben(einstellung) { + const betrieb = einstellung?.betrieb || {} + const fehlt = [] + if (typeof betrieb.telefon !== 'string' || betrieb.telefon.trim() === '') fehlt.push('betrieb.telefon') + if (zeigtWiderruf(einstellung) && (typeof betrieb.adresse !== 'string' || betrieb.adresse.trim() === '')) fehlt.push('betrieb.adresse') + return fehlt +} + // ---------------------------------------------------------------- Texte und Regeln /** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */ @@ -652,7 +693,7 @@ export function oeffentlicheEinstellung(einstellung, now) { zahlung: { modus: 'vorort', text: zahlungText(e) }, preisHinweis: preisHinweis(e), storno: e.storno?.text ? { text: e.storno.text, version: e.storno.version } : null, - widerruf: verbraucher && leistungen.some((l) => l.buchungsart === 'sofort'), + widerruf: zeigtWiderruf(e), erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher }, gastFelder: structuredClone(e.gastFelder), } diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index d861273..a4839d6 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -3,6 +3,7 @@ import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js import { analyseHosts } from '../analyse.js' import { withLocks } from '../keyedLock.js' import { findActiveBySiteKey } from '../siteFeatures.js' +import { fehlendePflichtangaben } from './einstellungen.js' import { termineAus } from './schalter.js' import { pruefeNow, pruefeText } from './util.js' @@ -18,7 +19,8 @@ import { pruefeNow, pruefeText } from './util.js' * Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck. * * - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter - * Einstellung; Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher + * Einstellung, in der die Pflichtangaben fuer Gaeste stehen (fehlendePflichtangaben, Ruling R41: ein Entwurf aus einer + * Vorlage ist noch nicht buchbar); Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher * gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung * leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation). * - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen), @@ -98,7 +100,7 @@ export async function kontextPerSiteKey(siteKey, now = new Date()) { const stand = generation const feature = await findActiveBySiteKey(siteKey, FUNKTION) let ctx = feature ? await baueKontext(feature) : null - if (ctx && !ctx.einstellung) ctx = null + if (ctx && (!ctx.einstellung || fehlendePflichtangaben(ctx.einstellung).length > 0)) ctx = null if (stand === generation) merke(siteKey, ctx, nowMs) return ctx } @@ -135,22 +137,32 @@ const veraltet = () => * Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt * { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre * termine-cfg:. Danach ist der Speicher von kontextPerSiteKey leer. + * Statt der Einstellung darf eine Funktion `async (bisher) => einstellung` uebergeben werden (Ruling R40): sie laeuft + * INNERHALB der Sperre nach der Versionspruefung, bekommt die gespeicherte Einstellung (Objekt) oder null und liefert die + * zu speichernde; sie darf werfen (der Fehler geht unveraendert durch, es wird nichts geschrieben). So laufen Pruefungen, + * die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie + * das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand + * aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch. * @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument - * @param {object} einstellung - * @param {{ version: number, by?: string, now?: Date }} optionen + * @param {object|((bisher: object|null) => object|Promise)} einstellungOderFn + * @param {{ version: number|null, by?: string, now?: Date }} optionen * @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version */ -export async function speichereEinstellung(feature, einstellung, { version, by = '', now = new Date() } = {}) { +export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date() } = {}) { pruefeText(feature?.$id, 'feature.$id') - if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt') + const alsFunktion = typeof einstellungOderFn === 'function' + if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt') const nowMs = pruefeNow(now, 'speichereEinstellung') - const json = JSON.stringify(einstellung) return withLocks([`termine-cfg:${feature.$id}`], async () => { const col = config.collections.termineConfig const vorhanden = await holeOderNull(col, feature.$id) const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0 - if (version !== aktuell) throw veraltet() + if (version !== null && version !== aktuell) throw veraltet() + + const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? (liesEinstellung(vorhanden)?.einstellung ?? null) : null) : einstellungOderFn + if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt') + const json = JSON.stringify(einstellung) const felder = { customerId: feature.customerId, diff --git a/server/services/termine/vorlagen.js b/server/services/termine/vorlagen.js index d59c352..66ccaa4 100644 --- a/server/services/termine/vorlagen.js +++ b/server/services/termine/vorlagen.js @@ -175,9 +175,11 @@ export function vorlagenListe() { } /** - * Gueltige Einstellung aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer, + * Einstellung (Entwurf) aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer, * impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf. - * Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler. + * Telefon und Anschrift duerfen fehlen (normalisiereEinstellung mit entwurf); fehlendePflichtangaben(einstellung) nennt, + * was vor dem oeffentlichen Betrieb noch gebraucht wird. Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein + * Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler. */ export function ausVorlage(key, betrieb, now) { if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`) @@ -208,7 +210,8 @@ export function ausVorlage(key, betrieb, now) { recht: { ...v.recht }, zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] }, } - const ergebnis = normalisiereEinstellung(eingabe, { now }) + // Entwurf: ein Kunde ohne Telefon/Anschrift im Konto kann trotzdem starten; die Angaben holt das Portal nach (fehlendePflichtangaben) + const ergebnis = normalisiereEinstellung(eingabe, { now, entwurf: true }) if (ergebnis.fehler) { const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.') err.fehler = ergebnis.fehler diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index c827ca0..72214c9 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -36,6 +36,7 @@ const MOUNTS = [ ['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default], ['/api/abo', (await import('../server/routes/abo.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default], + ['/api/termine', (await import('../server/routes/termine.js')).default], ] // Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist const oeffentlich = (await import('../server/routes/publicTermine.js')).default diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js index b11261c..16b3143 100644 --- a/test/publicTermine.test.js +++ b/test/publicTermine.test.js @@ -305,13 +305,23 @@ test('config: ohne Widerrufsrecht (nur Unternehmer) kein widerruf und keine Bele assert.deepEqual(Object.keys(body.texte).sort(), ['erinnerung', 'gesundheit', 'storno', 'vorzeitig']) }) -test('config: eine nicht zu erzeugende Belehrung (Anschrift fehlt) ist 503 statt einer Buchung ohne Belehrung', async () => { - const e = einstellung() - e.betrieb.adresse = '' - await speichere(e) - const res = await api('GET', `/${SITE_KEY}/config`) - assert.equal(res.status, 503) - assert.deepEqual(res.body, NICHT_ERREICHBAR) +test('config: ohne Anschrift (Belehrung nicht zu erzeugen) oder Telefon ist die Funktion fuer Gaeste nicht aktiv (R41), nie eine Buchung ohne Belehrung', async () => { + for (const felder of [{ adresse: '' }, { telefon: '' }]) { + const e = einstellung() + Object.assign(e.betrieb, felder) // wie ein Entwurf aus einer Vorlage, gespeichert ohne strenge Pruefung + await speichere(e) + for (const [methode, pfad, body] of [ + ['GET', 'config'], + ['GET', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`], + ['GET', `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`], + ['POST', 'buchungen', buchungBody()], + ]) { + const res = await api(methode, `/${SITE_KEY}/${pfad}`, body ? { body } : {}) + assert.equal(res.status, 200, `${JSON.stringify(felder)} ${pfad}`) + assert.deepEqual(res.body, AKTIV_FALSE, `${JSON.stringify(felder)} ${pfad}`) + } + assert.equal(aw.docs('termineBookings').size, 0, 'nichts gebucht') + } }) // ---------------------------------------------------------------- Unbekannt, aus, abgeschaltet diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index 30217e5..13fb369 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -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@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://ab.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') }) diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index 146dfdb..ad1b2b0 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -82,17 +82,17 @@ const eingabe = (over = {}) => ({ zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, ...over, }) -const einstellung = (over) => { - const erg = normalisiereEinstellung(eingabe(over), { now: JETZT }) +const einstellung = (over, optionen = {}) => { + const erg = normalisiereEinstellung(eingabe(over), { now: JETZT, ...optionen }) assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) return erg.einstellung } const betriebMit = (felder) => ({ ...eingabe().betrieb, ...felder }) -const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }) => ({ +const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }, optionen = {}) => ({ feature: feature(), project: projekt, - einstellung: einstellung(over), + einstellung: einstellung(over, optionen), version: 1, }) @@ -264,6 +264,88 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh }) }) +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' }) }) + const erg = await speichereEinstellung(feature(), async (bisher) => { + aufrufe.push(bisher) + return erste + }, { version: 0, by: 'kunde', now: JETZT }) + assert.deepEqual(aufrufe, [null], 'noch nichts gespeichert') + assert.equal(erg.version, 1) + assert.deepEqual(erg.einstellung, erste) + assert.equal(gespeichert().updatedBy, 'kunde') + + const zweite = einstellung({ betrieb: betriebMit({ name: 'Zweite' }) }) + const erg2 = await speichereEinstellung(feature(), (bisher) => { + aufrufe.push(bisher) // auch eine synchrone Funktion + return zweite + }, { version: 1 }) + assert.deepEqual(aufrufe[1], erste, 'bisher = die gespeicherte Einstellung') + assert.equal(erg2.version, 2) + assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Zweite') +}) + +test('R40 speichereEinstellung mit Funktion: veraltete Version ruft die Funktion nie auf; ein Fehler der Funktion geht unveraendert durch und schreibt nichts', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + let aufrufe = 0 + await assert.rejects( + () => speichereEinstellung(feature(), async () => { aufrufe += 1; return einstellung() }, { version: 0 }), + (err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET', + ) + assert.equal(aufrufe, 0, 'Version zuerst') + + const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { 'leistungen.s_x': 'Noch 2 künftige Termine.' } }) + await assert.rejects(() => speichereEinstellung(feature(), async () => { throw eigener }, { version: 1 }), (err) => err === eigener) + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 1, 'nichts geschrieben') + // das Sperrschloss ist wieder frei + assert.equal((await speichereEinstellung(feature(), async () => einstellung(), { version: 1 })).version, 2) +}) + +test('R40 speichereEinstellung mit Funktion: Ergebnis ohne Objekt => TypeError und nichts geschrieben; Eingaben ohne Funktion bleiben TypeError', async () => { + for (const unsinn of [null, undefined, 'x', 5, []]) { + await assert.rejects(() => speichereEinstellung(feature(), async () => unsinn, { version: 0 }), TypeError, String(unsinn)) + } + assert.equal(aw.docs('termineConfig').size, 0) + await assert.rejects(() => speichereEinstellung({}, async () => einstellung(), { version: 0 }), TypeError) +}) + +test('R40 speichereEinstellung mit version null: keine Versionspruefung, Version zaehlt trotzdem hoch (Feed-Link neu erzeugen)', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zwei' }) }), { version: 1 }) + const erg = await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null }) + assert.equal(erg.version, 3) + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.einstellung.farbe, '#112233') + assert.equal(geladen.einstellung.betrieb.name, 'Zwei', 'baut auf dem aktuellen Stand auf') + // nur null spart die Pruefung; undefined und falsche Versionen bleiben 409 + await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, {}), (err) => err.status === 409) + await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, { version: 99 }), (err) => err.status === 409) + // ohne Dokument sieht die Funktion null (mit der Einstellung als Objekt bleibt version null auch erlaubt) + await deleteDocument('termineConfig', FEATURE) + let gesehen + await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: null }) + assert.equal(gesehen, null) +}) + +test('R40 speichereEinstellung mit Funktion: gleichzeitige Aufrufe laufen nacheinander, der zweite sieht das Ergebnis des ersten', async () => { + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 }) + const gesehen = [] + const aendere = (name) => async (bisher) => { + gesehen.push(bisher.betrieb.name) + await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken + return { ...bisher, betrieb: { ...bisher.betrieb, name } } + } + await Promise.all([ + speichereEinstellung(feature(), aendere('A'), { version: null }), + speichereEinstellung(feature(), aendere('B'), { version: null }), + ]) + assert.equal(gesehen[0], 'Start') + assert.ok(['A', 'B'].includes(gesehen[1]), 'der Zweite baut auf dem Ergebnis des Ersten auf') + assert.equal((await ladeEinstellung(FEATURE)).version, 3) +}) + // ---------------------------------------------------------------- Kontext test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, project, einstellung, version }', async () => { @@ -279,6 +361,32 @@ test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, proje assert.equal(ctx.version, 1) }) +test('R41 kontextPerSiteKey: solange Pflichtangaben fehlen (Telefon; Anschrift bei Widerrufsbelehrung) ist die Funktion oeffentlich nicht buchbar (null); das Portal sieht sie weiter', async () => { + seedFeature() + seedProjekt() + const entwurf = { entwurf: true } + const ohneTelefon = einstellung({ betrieb: betriebMit({ telefon: '' }) }, entwurf) + await speichereEinstellung(feature(), ohneTelefon, { version: 0 }) + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'Telefon fehlt') + const portal = await kontextPerFeature(FEATURE) + assert.ok(portal && portal.einstellung, 'kontextPerFeature (Portal, Gaesteseiten per Token) bleibt erreichbar') + assert.deepEqual(portal.einstellung.betrieb.telefon, '') + + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }) }, entwurf), { version: 1 }) + assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)), null, 'Anschrift fehlt bei Verbraucher + sofort') + + // ohne Widerrufsbelehrung (nur Unternehmer) reicht das Telefon + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, entwurf), { version: 2 }) + assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(2)), 'Unternehmer-Betrieb ohne Anschrift ist buchbar') + + // vollstaendig: buchbar; die Speicherung leert den gemerkten null-Eintrag sofort + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '', telefon: '' }) }, entwurf), { version: 3 }) + assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(3)), null) + await speichereEinstellung(feature(), einstellung(), { version: 4 }) + const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(4)) + assert.equal(ctx.einstellung.betrieb.telefon, '06301 1234') +}) + test('kontextPerSiteKey: Modus off => null ohne Anfrage; ungueltiger siteKey => null ohne Anfrage', async () => { seedFeature() await speichereEinstellung(feature(), einstellung(), { version: 0 }) @@ -604,12 +712,14 @@ test('G02 Freizeitleistung mit festem Termin: kein_widerruf statt widerruf (nich }) test('G02 mit Widerrufsrecht, aber ohne Anschrift oder Telefon des Betriebs: Fehler statt unvollstaendiger Belehrung', () => { + // Entwurf: nur so gibt es eine Einstellung ohne die Pflichtangaben (R41); die Mail prueft sie trotzdem selbst + const entwurf = { entwurf: true } for (const felder of [{ adresse: '' }, { telefon: '' }]) { - const ctx = ctxMit({ betrieb: betriebMit(felder) }) + const ctx = ctxMit({ betrieb: betriebMit(felder) }, undefined, entwurf) assert.throws(() => werteFuer('G02', ctx, buchung(), {}), TypeError, JSON.stringify(felder)) } // ohne Widerrufsrecht braucht die Mail beides nicht - const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }) + const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, undefined, entwurf) assert.doesNotThrow(() => werteFuer('G02', ctx, buchung({ verbraucher: false }), {})) }) @@ -686,7 +796,8 @@ test('Impressum und Datenschutz im Fuss: Angaben des Betriebs, sonst dessen Webs assert.equal(nackt.impressum_url, config.publicBaseUrl) assert.equal(nackt.datenschutz_url, config.publicBaseUrl) - const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }), buchung(), {}).values + // Entwurf (R41): nur so gibt es eine Einstellung ohne Telefon und Anschrift; die Mail setzt dann "–" + const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }, undefined, { entwurf: true }), buchung(), {}).values assert.equal(leer.betrieb_telefon, '–') assert.equal(leer.betrieb_adresse, '–') assert.equal(mitAngaben.farbe, '#7c3aed') diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js new file mode 100644 index 0000000..893b8f6 --- /dev/null +++ b/test/termineRoutes.test.js @@ -0,0 +1,961 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Kunden-Schnittstelle /api/termine (Aufgabe 18): Zugang (Projekt, Pilot, Funktion), Einstellungen (Vorlage, PATCH mit Version +// und Entfernen-Pruefung, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Kundenansicht. +// Aufbau wie test/termineAblaufBetrieb.test.js (Nachbau von Appwrite mit Schema, Standard-Limit 25, feste Uhr, Mail-Senke). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + termineConfig: schemaKeys('termineConfig'), + termineBookings: schemaKeys('termineBookings'), + termineClaims: schemaKeys('termineClaims'), + termineOutbox: schemaKeys('termineOutbox'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, customers } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js') +const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js') +const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { speichereEinstellung, ladeEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { buchungsNummer } = await import('../server/services/termine/token.js') + +const app = await startTestApp({ + '/': kundenansichtGuard, + '/api/public/termine/v1': publicTermineRoutes, + '/api/termine': termineRoutes, +}) +after(async () => { + setMailTransportForTests(null) + await Promise.all([app.close(), aw.close(), st.close()]) +}) + +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ) +const TAG = '2026-10-12' // Montag, eine Woche spaeter +const KUNDE = 'c1' +const P = 'p1' +const FEATURE = 'f_p1_termine' +const SITE_KEY = 'abcdefghijklmnop' +const R1 = 'r_aaaaaaa1' +const R2 = 'r_aaaaaaa2' +const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort +const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage +const VIEW = 'a'.repeat(32) +const MODUS_VORHER = config.termine.mode +const MAILS_VORHER = [...config.termine.pilotEmails] +const BASIS_VORHER = config.publicBaseUrl +const ABSENDER_VORHER = config.smtp.from +config.publicBaseUrl = app.url // die Kalender-Links zeigen auf diese Test-App + +const cookie = customerCookie(KUNDE) +const iso = (date) => new Date(date).toISOString() +const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } }) + +const eingabeEinstellung = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + bundesland: 'RP', + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, + { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, + ], + leistungen: [ + { id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] }, + { id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] }, + ], + regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }, + gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] }, + erinnerung: { anbieten: true, stundenVorher: 24 }, + storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, + ...over, +}) +const einstellung = (over) => { + const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} + +const featureDoc = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: P, + featureKey: 'termine', + state: 'active', + enabled: true, + siteKey: SITE_KEY, + activatedAt: '2026-10-01T09:00:00.000Z', + ...over, +}) + +/** Einstellung speichern (wie das Portal es nach dem ersten Oeffnen hat). */ +async function speichere(e = einstellung(), feature = featureDoc()) { + resetKontextForTests() + const doc = aw.docs('termineConfig').get(feature.$id) + await speichereEinstellung(feature, e, { version: doc ? doc.version : 0, now: JETZT }) + return e +} + +const buchungsDoc = (id, over = {}) => ({ + $id: id, + featureId: FEATURE, + customerId: KUNDE, + projectId: P, + leistungId: S_SOFORT, + leistungName: 'Damenschnitt', + ressourceId: R1, + ressourceName: 'Anna', + startsAt: `${TAG}T08:00:00.000+00:00`, // 10:00 Berlin + endsAt: `${TAG}T08:45:00.000+00:00`, + blockEndsAt: `${TAG}T08:50:00.000+00:00`, + status: 'bestaetigt', + quelle: 'online', + gastName: 'Max Mustermann', + gastEmail: 'max@example.com', + gastTelefon: '0171 1234567', + gastNachricht: '', + erinnerung: false, + preisCent: 4500, + preisArt: 'fest', + buchungsart: 'sofort', + verbraucher: true, + icsSequenz: 0, + createdAt: '2026-10-05T07:00:00.000+00:00', + updatedAt: '2026-10-05T07:00:00.000+00:00', + ...over, +}) +const seedBuchung = (id, over) => aw.seed('termineBookings', buchungsDoc(id, over)) +const buchungen = () => [...aw.docs('termineBookings').values()] +const logEintraege = () => [...aw.docs('customerViewLog').values()] +const eintraege = () => [...aw.docs('termineOutbox').values()] + +let sink +beforeEach(async () => { + for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customerViewLog', 'customers', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + resetKontextForTests() + resetOutboxForTests() + resetTermineLimitsForTests() + resetPublicTermineForTests() + resetTermineRoutesForTests() + setzeUhrFuerTests(() => JETZT) + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] + config.smtp.from = 'noreply@webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + + // Kunde c1 mit Firma, Anschrift und Telefon im Konto; Kunde c2 ohne beides + aw.seed('customers', { ...customers.c1, companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', phone: '06301 1234' }) + aw.seed('customers', customers.c2) + aw.seed('websiteProjects', { $id: P, customerId: KUNDE, projectName: 'Salon', subdomain: 'salon' }) + aw.seed('websiteProjects', { $id: 'p2', customerId: KUNDE, projectName: 'Studio' }) + aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd' }) + aw.seed('websiteProjects', { $id: 'p4', customerId: KUNDE, projectName: 'Alt' }) + aw.seed('websiteProjects', { $id: 'p5', customerId: KUNDE, projectName: 'Ohne Funktion' }) + aw.seed('websiteProjects', { $id: 'p6', customerId: 'c2', projectName: 'Ohne Telefon', subdomain: 'ohne' }) + aw.seed('portalFeatures', featureDoc()) + aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_termine', projectId: 'p2', state: 'off', enabled: false, siteKey: 'bcdefghijklmnopq' })) + aw.seed('portalFeatures', featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' })) + aw.seed('portalFeatures', featureDoc({ $id: 'f_p6_termine', customerId: 'c2', projectId: 'p6', siteKey: 'defghijklmnopqrs' })) +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.termine.mode = MODUS_VORHER + config.termine.pilotEmails = [...MAILS_VORHER] + config.smtp.from = ABSENDER_VORHER +}) +after(() => { + config.publicBaseUrl = BASIS_VORHER +}) + +const einstellungenUrl = (projectId = P) => `/api/termine/einstellungen?projectId=${projectId}` +const lade = async (projectId = P) => (await send('GET', einstellungenUrl(projectId), { cookie })).body + +// ---------------------------------------------------------------- Zugang + +test('ohne Sitzung 401, ohne Projekt 400, fremdes Projekt 403 (auf jeder Route)', async () => { + await speichere() + assert.equal((await send('GET', einstellungenUrl())).status, 401) + assert.equal((await send('GET', '/api/termine/einstellungen', { cookie })).status, 400) + const fremd = [ + ['GET', einstellungenUrl('p3')], + ['GET', '/api/termine/buchungen?projectId=p3&von=2026-10-01&bis=2026-10-31'], + ['GET', '/api/termine/zeiten?projectId=p3'], + ['GET', '/api/termine/export.csv?projectId=p3'], + ['PATCH', '/api/termine/einstellungen', { projectId: 'p3', version: 0, einstellung: {} }], + ['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p3', vorlage: 'salon' }], + ['POST', '/api/termine/buchungen', { projectId: 'p3', art: 'sperre' }], + ['POST', '/api/termine/buchungen/x/aktion', { projectId: 'p3', aktion: 'annehmen' }], + ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p3' }], + ['DELETE', '/api/termine/buchungen/x?projectId=p3'], + ['GET', einstellungenUrl('gibt es nicht')], + ] + for (const [method, pfad, body] of fremd) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 403, `${method} ${pfad}`) + assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.') + } +}) + +test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegbar", auch beim Lesen', async () => { + await speichere() + config.termine.pilotEmails = ['andere@example.invalid'] + for (const [method, pfad, body] of [['GET', einstellungenUrl()], ['PATCH', '/api/termine/einstellungen', { projectId: P, version: 1, einstellung: {} }]]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.error, 'Der Terminplaner ist noch nicht verfügbar.') + } + config.termine.pilotEmails = [customers.c1.email] + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Adresse der Liste') + config.termine.mode = 'off' + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409) +}) + +test('Funktion aus, noch nicht bestellt oder ohne Dokument: 409 "nicht aktiv"', async () => { + for (const projectId of ['p2', 'p5']) { + for (const [method, pfad, body] of [ + ['GET', einstellungenUrl(projectId)], + ['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`], + ['GET', `/api/termine/export.csv?projectId=${projectId}`], + ['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }], + ['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }], + ]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${projectId} ${method} ${pfad}`) + assert.equal(res.body.error, 'Der Terminplaner ist für diese Website nicht aktiv.') + } + } + for (const state of ['ordered', 'setup']) { + aw.seed('portalFeatures', featureDoc({ $id: 'f_p5_termine', projectId: 'p5', state, enabled: false, siteKey: 'efghijklmnopqrst' })) + assert.equal((await send('GET', einstellungenUrl('p5'), { cookie })).status, 409, state) + } +}) + +// ---------------------------------------------------------------- Einstellungen: lesen, Vorlage + +test('GET einstellungen ohne Einstellung: Zustand, Vorlagen, Einbau-Code, siteKey, keine Einstellung', async () => { + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(res.status, 200) + const b = res.body + assert.equal(b.state, 'active') + assert.equal(b.aktivSeit, '2026-10-01T09:00:00.000Z') + assert.equal(b.einstellung, null) + assert.equal(b.version, 0) + assert.deepEqual(b.vorlagen.map((v) => v.key), ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges']) + assert.deepEqual(Object.keys(b.vorlagen[0]).sort(), ['beschreibung', 'key', 'name']) + assert.deepEqual(b.feeds, {}) + assert.equal(b.siteKey, SITE_KEY) + assert.equal(b.anfragenOffen, 0) + assert.deepEqual(b.fehlt, []) + assert.match(b.einbau.snippet, new RegExp(`
`)) + assert.ok(b.einbau.snippet.includes(``)) + assert.ok(b.einbau.snippet.includes('

Termine telefonisch vereinbaren.

'), 'noch kein Telefon bekannt') + assert.equal(res.headers.get('cache-control'), 'no-store') +}) + +test('GET einstellungen mit Einstellung: ohne Feed-Token, Kalender-Links je Person, Einbau-Code mit Telefon, offene Anfragen', async () => { + const e = await speichere(einstellung({ betrieb: { ...eingabeEinstellung().betrieb, telefon: '06301 <12>' } })) + seedBuchung('b_anfrage1', { status: 'angefragt', leistungId: S_ANFRAGE }) + seedBuchung('b_anfrage2', { status: 'angefragt', leistungId: S_ANFRAGE }) + seedBuchung('b_fest', { status: 'bestaetigt' }) + const res = await send('GET', einstellungenUrl(), { cookie }) + const b = res.body + assert.equal(b.version, 1) + assert.equal(b.anfragenOffen, 2) + assert.deepEqual(Object.keys(b.feeds).sort(), [R1, R2]) + for (const r of e.ressourcen) { + assert.equal(b.feeds[r.id], `${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/${r.feedToken}.ics`) + assert.ok(!JSON.stringify(b.einstellung).includes(r.feedToken), 'der Token steht nur im Link') + } + assert.ok(b.einstellung.ressourcen.every((r) => !('feedToken' in r))) + assert.equal(b.einstellung.betrieb.name, 'Salon Müller') + assert.ok(b.einbau.snippet.includes('

Termine telefonisch unter 06301 <12>.

'), 'Telefon im Einbau-Code maskiert') + // der Link funktioniert an der oeffentlichen Schnittstelle + const feed = await fetch(b.feeds[R1]) + assert.equal(feed.status, 200) + assert.match(feed.headers.get('content-type'), /^text\/calendar/) +}) + +test('POST vorlage: Betrieb aus dem Kundenkonto (Firma, Anschrift, E-Mail, Telefon), 201 mit Einstellung ohne Token; zweimal => 409', async () => { + const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } }) + assert.equal(res.status, 201) + assert.equal(res.body.version, 1) + assert.deepEqual(res.body.fehlt, []) + const betrieb = res.body.einstellung.betrieb + assert.equal(betrieb.name, 'Salon Müller') + assert.equal(betrieb.email, 'k@example.invalid') + assert.equal(betrieb.telefon, '06301 1234') + assert.equal(betrieb.adresse, 'Hauptstr. 1, 67722 Winnweiler') + assert.equal(res.body.einstellung.vorlage, 'salon') + assert.ok(res.body.einstellung.leistungen.length >= 2) + assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r))) + const gespeichert = await ladeEinstellung(FEATURE) + assert.equal(gespeichert.version, 1) + assert.ok(gespeichert.einstellung.ressourcen.every((r) => r.feedToken.length === 43)) + assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1') + + const zweite = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'tattoo' } }) + assert.equal(zweite.status, 409) + assert.equal(zweite.body.code, 'einstellung_vorhanden') + assert.equal((await ladeEinstellung(FEATURE)).einstellung.vorlage, 'salon', 'nichts ueberschrieben') + + // zwei gleichzeitige Anfragen auf eine frische Funktion: genau eine gewinnt + await deleteDocument('termineConfig', FEATURE) + const [a, b] = await Promise.all(['salon', 'kosmetik'].map((vorlage) => send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } }))) + assert.deepEqual([a.status, b.status].sort(), [201, 409]) +}) + +test('POST vorlage: unbekannte, fehlende und Prototyp-Schluessel => 400', async () => { + for (const vorlage of ['gibtsnicht', '__proto__', 'constructor', 'toString', '', null, 5, undefined]) { + const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } }) + assert.equal(res.status, 400, String(vorlage)) + assert.ok(res.body.fehler.vorlage) + } + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('POST vorlage ohne Telefon und Anschrift im Konto: Entwurf mit "fehlt"; oeffentlich nicht aktiv, bis die Angaben da sind (R41)', async () => { + const c2 = customerCookie('c2') + const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie: c2, body: { projectId: 'p6', vorlage: 'salon' } }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.deepEqual(res.body.fehlt, ['betrieb.telefon', 'betrieb.adresse']) + assert.equal(res.body.einstellung.betrieb.name, 'Kunde Zwei', 'ohne Firma der Name') + const gelesen = await send('GET', einstellungenUrl('p6'), { cookie: c2 }) + assert.deepEqual(gelesen.body.fehlt, ['betrieb.telefon', 'betrieb.adresse']) + + const oeffentlich = (siteKey) => fetch(`${app.url}/api/public/termine/v1/${siteKey}/config`).then((r) => r.json()) + assert.deepEqual(await oeffentlich('defghijklmnopqrs'), { aktiv: false }) + + // PATCH prueft immer streng + const entwurf = gelesen.body.einstellung + const abgelehnt = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: entwurf } }) + assert.equal(abgelehnt.status, 400) + assert.deepEqual(Object.keys(abgelehnt.body.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon']) + assert.match(abgelehnt.body.error, /Telefonnummer/) + + const komplett = { ...entwurf, betrieb: { ...entwurf.betrieb, telefon: '0151 2345678', adresse: 'Weg 1, 12345 Ort' } } + const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.deepEqual((await send('GET', einstellungenUrl('p6'), { cookie: c2 })).body.fehlt, []) + assert.equal((await oeffentlich('defghijklmnopqrs')).aktiv, true) +}) + +// ---------------------------------------------------------------- Einstellungen: PATCH + +test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (auch bei Wert vom Client), neue Personen bekommen einen', async () => { + await speichere() + const vorher = await lade() + const tokenVorher = (await ladeEinstellung(FEATURE)).einstellung.ressourcen.map((r) => r.feedToken) + + const neu = structuredClone(vorher.einstellung) + neu.betrieb.name = 'Salon Neu' + neu.ressourcen[0].feedToken = 'x'.repeat(43) // wird ignoriert + neu.ressourcen.push({ name: 'Clara', typ: 'person', plan: { 2: [['09:00', '12:00']] } }) + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: vorher.version, einstellung: neu } }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'version']) + assert.equal(res.body.version, 2) + assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu') + assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r))) + + const gespeichert = (await ladeEinstellung(FEATURE)).einstellung + assert.deepEqual(gespeichert.ressourcen.slice(0, 2).map((r) => r.feedToken), tokenVorher) + assert.equal(gespeichert.ressourcen[2].feedToken.length, 43) + assert.equal(Object.keys((await lade()).feeds).length, 3) + assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1') +}) + +test('PATCH einstellungen: alte Version => 409 EINSTELLUNG_VERALTET, Stand des Ersten bleibt', async () => { + await speichere() + const stand = await lade() + const aendere = (name, version = stand.version) => send('PATCH', '/api/termine/einstellungen', { + cookie, + body: { projectId: P, version, einstellung: { ...stand.einstellung, betrieb: { ...stand.einstellung.betrieb, name } } }, + }) + assert.equal((await aendere('Erster')).status, 200) + const zweiter = await aendere('Zweiter') + assert.equal(zweiter.status, 409) + assert.equal(zweiter.body.code, 'EINSTELLUNG_VERALTET') + assert.equal(zweiter.body.error, 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.') + assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Erster') + assert.equal((await aendere('Zukunft', 9)).status, 409) +}) + +test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => { + await speichere() + const stand = await lade() + for (const version of [undefined, null, '1', 1.5, -1, [], {}]) { + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: stand.einstellung } }) + assert.equal(res.status, 400, JSON.stringify(version)) + assert.ok(res.body.fehler.version) + } + for (const einstellungWert of [null, 'x', 5, [], undefined]) { + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: einstellungWert } }) + assert.equal(res.status, 400, JSON.stringify(einstellungWert)) + assert.ok(res.body.fehler._) + } + const kaputt = structuredClone(stand.einstellung) + kaputt.betrieb.impressumUrl = 'javascript:alert(1)' + kaputt.farbe = 'rot' + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: kaputt } }) + assert.equal(res.status, 400) + assert.deepEqual(Object.keys(res.body.fehler).sort(), ['betrieb.impressumUrl', 'farbe']) + assert.equal(res.body.error, res.body.fehler['betrieb.impressumUrl']) + assert.equal((await ladeEinstellung(FEATURE)).version, 1, 'nichts gespeichert') +}) + +test('PATCH einstellungen: Leistung oder Person mit kuenftigen Terminen loeschen => 400 mit Anzahl, Deaktivieren geht', async () => { + await speichere() + seedBuchung('b_a', { leistungId: S_ANFRAGE, status: 'angefragt' }) + seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'bestaetigt', ressourceId: R2 }) + seedBuchung('b_vorbei', { leistungId: S_ANFRAGE, startsAt: '2026-10-01T08:00:00.000+00:00', endsAt: '2026-10-01T09:00:00.000+00:00', blockEndsAt: '2026-10-01T09:00:00.000+00:00' }) + const stand = await lade() + const patch = (einstellungWert, version = stand.version) => send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: einstellungWert } }) + + const ohneLeistung = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.filter((l) => l.id !== S_ANFRAGE) } + const res = await patch(ohneLeistung) + assert.equal(res.status, 400) + assert.equal(res.body.fehler[`leistungen.${S_ANFRAGE}`], 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.') + assert.match(res.body.error, /2 künftige Termine/) + + // ein abgesagter Termin zaehlt nicht mehr + seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'abgesagt', ressourceId: R2 }) + const eins = await patch(ohneLeistung) + assert.equal(eins.status, 400) + assert.match(eins.body.error, /1 künftigen Termin\b/) + + const ohnePerson = { ...stand.einstellung, ressourcen: stand.einstellung.ressourcen.filter((r) => r.id !== R1) } + const person = await patch(ohnePerson) + assert.equal(person.status, 400) + assert.ok(person.body.fehler[`ressourcen.${R1}`], 'auch die Person mit Terminen bleibt') + + // deaktivieren: erlaubt, die Termine bleiben + const deaktiviert = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.map((l) => (l.id === S_ANFRAGE ? { ...l, aktiv: false } : l)) } + const ok = await patch(deaktiviert) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(ok.body.einstellung.leistungen.find((l) => l.id === S_ANFRAGE).aktiv, false) + assert.equal(buchungen().length, 3) + + // ohne Termine: loeschen erlaubt + for (const id of [...aw.docs('termineBookings').keys()]) await deleteDocument('termineBookings', id) + const geloescht = await patch(ohneLeistung, ok.body.version) + assert.equal(geloescht.status, 200, JSON.stringify(geloescht.body)) +}) + +// ---------------------------------------------------------------- Kalender-Link + +test('POST ressourcen/:id/feed: neuer Link nur fuer diese Person, alter Link ungueltig, Version zaehlt hoch', async () => { + await speichere() + const vorher = await lade() + const res = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.match(res.body.url, new RegExp(`^${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/[A-Za-z0-9_-]{43}\\.ics$`)) + assert.notEqual(res.body.url, vorher.feeds[R1]) + assert.equal(res.body.version, 2, 'die Einstellungsseite braucht die neue Version fuer ihr naechstes Speichern') + + const nachher = await lade() + assert.equal(nachher.feeds[R1], res.body.url) + assert.equal(nachher.feeds[R2], vorher.feeds[R2], 'die andere Person behaelt ihren Link') + assert.equal(nachher.version, 2) + assert.equal((await fetch(vorher.feeds[R1])).status, 404) + assert.equal((await fetch(res.body.url)).status, 200) + assert.equal((await fetch(vorher.feeds[R2])).status, 200) + + // sonst bleibt alles gleich + assert.deepEqual(nachher.einstellung, vorher.einstellung) +}) + +test('POST ressourcen/:id/feed: unbekannte Person oder keine Einstellung => 404, nichts geschrieben', async () => { + const ohne = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } }) + assert.equal(ohne.status, 404) + assert.equal(aw.docs('termineConfig').size, 0) + await speichere() + for (const id of ['r_unbekannt', '__proto__', 'x'.repeat(200)]) { + const res = await send('POST', `/api/termine/ressourcen/${id}/feed`, { cookie, body: { projectId: P } }) + assert.equal(res.status, 404, id) + assert.equal(res.body.error, 'Diese Person oder diesen Platz gibt es nicht.') + } + assert.equal((await ladeEinstellung(FEATURE)).version, 1) +}) + +// ---------------------------------------------------------------- Buchungen lesen + +test('GET buchungen: Felder, Sortierung, Zeitraum, Statusfilter, Sperren, Zustellfehler', async () => { + await speichere() + seedBuchung('b_spaet', { startsAt: '2026-10-12T13:00:00.000+00:00', endsAt: '2026-10-12T13:45:00.000+00:00', blockEndsAt: '2026-10-12T13:50:00.000+00:00', status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', gastName: 'Spät', erinnerung: true }) + seedBuchung('b_frueh', { angenommenAt: '2026-10-05T07:30:00.000+00:00', abgesagtVon: '', gastNachricht: 'Bitte ohne Föhnen' }) + seedBuchung('b_abgesagt', { + startsAt: '2026-10-13T08:00:00.000+00:00', endsAt: '2026-10-13T08:45:00.000+00:00', blockEndsAt: '2026-10-13T08:50:00.000+00:00', + status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krank', widerrufenAt: '2026-10-06T10:00:00.000+00:00', + }) + seedBuchung('b_sperre', { + startsAt: '2026-10-12T10:00:00.000+00:00', endsAt: '2026-10-12T11:00:00.000+00:00', blockEndsAt: '2026-10-12T11:00:00.000+00:00', + quelle: 'sperre', leistungId: '', leistungName: 'Mittagspause', gastName: '', gastEmail: '', gastTelefon: '', preisCent: 0, preisArt: 'keiner', verbraucher: false, + }) + seedBuchung('b_oktober_spaet', { startsAt: '2026-11-20T08:00:00.000+00:00', endsAt: '2026-11-20T08:45:00.000+00:00', blockEndsAt: '2026-11-20T08:50:00.000+00:00' }) + seedBuchung('b_anderer', { featureId: 'f_anderer_termine' }) + aw.seed('termineOutbox', { $id: 'o1', featureId: FEATURE, bookingId: 'b_frueh', art: 'G02', status: 'fehler', an: 'max@example.com' }) + aw.seed('termineOutbox', { $id: 'o2', featureId: FEATURE, bookingId: 'b_spaet', art: 'G02', status: 'gesendet', an: 'max@example.com' }) + + const res = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13', { cookie }) + assert.equal(res.status, 200) + assert.deepEqual(res.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre', 'b_spaet', 'b_abgesagt'], 'nach Beginn, ohne fremde Funktion und ohne November') + const [frueh, sperre, spaet, abgesagt] = res.body.buchungen + assert.deepEqual(Object.keys(frueh), [ + 'id', 'nummer', 'status', 'quelle', 'startsAt', 'endsAt', 'datum', 'zeit', 'leistungId', 'leistung', 'dauer', 'titel', 'person', 'ressourceId', + 'gastName', 'gastEmail', 'gastTelefon', 'gastNachricht', 'erinnerung', 'zustellFehler', 'angenommenAt', 'widerrufenAt', 'abgesagtVon', 'absageGrund', + ]) + assert.deepEqual(frueh, { + id: 'b_frueh', nummer: buchungsNummer('b_frueh'), status: 'bestaetigt', quelle: 'online', + startsAt: '2026-10-12T08:00:00.000Z', endsAt: '2026-10-12T08:45:00.000Z', datum: '2026-10-12', zeit: '10:00', + leistungId: S_SOFORT, leistung: 'Damenschnitt', dauer: 45, titel: 'Damenschnitt: Max Mustermann', person: 'Anna', ressourceId: R1, + gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 1234567', gastNachricht: 'Bitte ohne Föhnen', + erinnerung: false, zustellFehler: true, angenommenAt: '2026-10-05T07:30:00.000Z', widerrufenAt: null, abgesagtVon: '', absageGrund: '', + }) + assert.match(frueh.nummer, /^[A-Z0-9]{8}$/) + assert.equal(spaet.zeit, '15:00') + assert.equal(spaet.erinnerung, true) + assert.equal(spaet.zustellFehler, false, 'gesendet ist kein Fehler') + assert.equal(sperre.quelle, 'sperre') + assert.equal(sperre.leistung, '') + assert.equal(sperre.titel, 'Mittagspause') + assert.equal(sperre.dauer, 60) + assert.equal(sperre.zustellFehler, false) + assert.equal(abgesagt.abgesagtVon, 'betrieb') + assert.equal(abgesagt.absageGrund, 'Krank', 'wie gespeichert') + assert.equal(abgesagt.widerrufenAt, '2026-10-06T10:00:00.000Z') + + const gefiltert = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13&status=angefragt,abgesagt', { cookie }) + assert.deepEqual(gefiltert.body.buchungen.map((b) => b.id), ['b_spaet', 'b_abgesagt']) + const eins = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-12&status=bestaetigt', { cookie }) + assert.deepEqual(eins.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre']) + const leer = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-02', { cookie }) + assert.deepEqual(leer.body, { buchungen: [] }) +}) + +test('GET buchungen: Zeitraum hoechstens 62 Tage, von vor bis, gueltige Tage, nur bekannte Status', async () => { + const url = (query) => `/api/termine/buchungen?projectId=p1&${query}` + assert.equal((await send('GET', url('von=2026-10-01&bis=2026-12-02'), { cookie })).status, 200, '62 Tage Abstand') + for (const [query, feld] of [ + ['von=2026-10-01&bis=2026-12-03', 'bis'], + ['von=2026-10-10&bis=2026-10-09', 'bis'], + ['von=2026-10-01', 'von'], + ['bis=2026-10-01', 'von'], + ['von=heute&bis=morgen', 'von'], + ['von=2026-02-30&bis=2026-03-01', 'von'], + ['von=2026-10-01&bis=2026-10-02&status=bestaetigt,kaputt', 'status'], + ['von=2026-10-01&bis=2026-10-02&status=bestaetigt&status=abgesagt', 'status'], + ['von=2026-10-01&bis=2026-10-02&status=,', 'status'], + ]) { + const res = await send('GET', url(query), { cookie }) + assert.equal(res.status, 400, query) + assert.ok(res.body.fehler[feld], query) + } +}) + +test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buchung wieder frei; Eingabe geprueft', async () => { + await speichere() + const zeiten = (query) => send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie }) + + // Montag, 10:00 Berlin = jetzt: Gaeste sehen wegen vorlaufMin 120 erst ab 12:00, der Betrieb ab jetzt + const heute = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2026-10-05`) + assert.equal(heute.status, 200) + assert.deepEqual(Object.keys(heute.body), ['zeiten']) + assert.equal(heute.body.zeiten[0], '10:00') + assert.equal(heute.body.zeiten.at(-1), '17:00') + // 'egal' ist die Vorgabe + assert.deepEqual((await zeiten(`leistung=${S_SOFORT}&tag=2026-10-05`)).body.zeiten, heute.body.zeiten) + // jenseits des Buchungszeitraums (90 Tage) + assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2027-03-01`)).body.zeiten.length > 0) + + seedBuchung('b_belegt') + const belegt = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`) + assert.ok(!belegt.body.zeiten.includes('10:00')) + assert.ok(!belegt.body.zeiten.includes('10:30')) + assert.ok(belegt.body.zeiten.includes('09:00')) + const mit = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}&ohne=b_belegt`) + assert.ok(mit.body.zeiten.includes('10:00'), 'die verschobene Buchung belegt ihre neue Zeit nicht selbst') + assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=egal&tag=${TAG}`)).body.zeiten.includes('10:00'), 'bei egal ist Ben frei') + + for (const [query, feld] of [ + [`ressource=${R1}&tag=${TAG}`, 'leistung'], + [`leistung=s_unbekannt&tag=${TAG}`, 'leistung'], + [`leistung=${S_SOFORT}&ressource=r_unbekannt&tag=${TAG}`, 'ressource'], + [`leistung=${S_SOFORT}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'], + [`leistung=${S_SOFORT}`, 'tag'], + [`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'], + [`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'], + ]) { + const res = await zeiten(query) + assert.equal(res.status, 400, query) + assert.ok(res.body.fehler[feld], query) + } +}) + +// ---------------------------------------------------------------- Buchungen schreiben + +const manuellBody = (over = {}) => ({ projectId: P, art: 'manuell', leistung: S_SOFORT, ressource: R1, tag: TAG, zeit: '11:00', name: 'Paula Telefon', telefon: '06301 555', ...over }) + +test('POST buchungen manuell: 201 mit Nummer, Handeintrag bestaetigt und belegt; Fehler 400, belegte Zeit 409; Liste zeigt ihn', async () => { + await speichere() + const res = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['id', 'nummer', 'status']) + assert.equal(res.body.status, 'bestaetigt') + assert.equal(res.body.nummer, buchungsNummer(res.body.id)) + const doc = aw.docs('termineBookings').get(res.body.id) + assert.equal(doc.quelle, 'manuell') + assert.equal(doc.gastName, 'Paula Telefon') + assert.equal(doc.featureId, FEATURE) + assert.equal(aw.docs('termineClaims').size, 10, '50 Minuten in 5-Minuten-Zellen') + + const liste = await send('GET', `/api/termine/buchungen?projectId=p1&von=${TAG}&bis=${TAG}`, { cookie }) + assert.equal(liste.body.buchungen[0].quelle, 'manuell') + assert.equal(liste.body.buchungen[0].zeit, '11:00') + + const doppelt = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: 'Zweite' }) }) + assert.equal(doppelt.status, 409) + assert.equal(doppelt.body.code, 'SLOT_VERGEBEN') + assert.ok(doppelt.body.fehler.zeit) + + const ohneName = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: '', zeit: '13:00' }) }) + assert.equal(ohneName.status, 400) + assert.ok(ohneName.body.fehler.name) + for (const art of [undefined, 'online', 'Sperre', null, 5]) { + const kaputt = await send('POST', '/api/termine/buchungen', { cookie, body: { ...manuellBody(), art } }) + assert.equal(kaputt.status, 400, String(art)) + assert.ok(kaputt.body.fehler.art) + } + assert.equal(buchungen().length, 1) +}) + +test('POST buchungen sperre und DELETE: Zeit sperren (201), doppelt 409, nur Sperren loeschbar (200), alles andere 404', async () => { + await speichere() + seedBuchung('b_normal') + const sperre = { projectId: P, art: 'sperre', ressource: R1, tag: '2026-10-13', von: '12:00', bis: '13:00', titel: 'Mittag' } + const res = await send('POST', '/api/termine/buchungen', { cookie, body: sperre }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.status, 'bestaetigt') + assert.equal(aw.docs('termineBookings').get(res.body.id).quelle, 'sperre') + + const nochmal = await send('POST', '/api/termine/buchungen', { cookie, body: sperre }) + assert.equal(nochmal.status, 409) + assert.equal(nochmal.body.code, 'SLOT_VERGEBEN') + assert.ok(nochmal.body.fehler.von) + const zulang = await send('POST', '/api/termine/buchungen', { cookie, body: { ...sperre, von: '08:00', bis: '18:00' } }) + assert.equal(zulang.status, 400) + assert.ok(zulang.body.fehler.bis) + + // ein Termin ist keine Sperre + const normal = await send('DELETE', `/api/termine/buchungen/b_normal?projectId=${P}`, { cookie }) + assert.equal(normal.status, 404) + assert.equal(normal.body.error, 'Diese Sperre gibt es nicht.') + assert.ok(aw.docs('termineBookings').has('b_normal')) + + const weg = await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie }) + assert.equal(weg.status, 200) + assert.deepEqual(weg.body, { status: 'entsperrt' }) + assert.ok(!aw.docs('termineBookings').has(res.body.id)) + assert.equal((await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie })).status, 404) + assert.equal((await send('DELETE', `/api/termine/buchungen/b_normal`, { cookie })).status, 400, 'ohne projectId') +}) + +test('POST buchungen und Aktionen ohne gespeicherte Einstellung => 409 "zuerst einrichten"', async () => { + for (const [method, pfad, body] of [ + ['POST', '/api/termine/buchungen', manuellBody()], + ['POST', '/api/termine/buchungen/x/aktion', { projectId: P, aktion: 'annehmen' }], + ['DELETE', `/api/termine/buchungen/x?projectId=${P}`], + ['GET', `/api/termine/zeiten?projectId=${P}&leistung=${S_SOFORT}&tag=${TAG}`], + ]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.code, 'keine_einstellung') + } +}) + +test('POST aktion annehmen: Anfrage bestaetigt, G02 an den Gast; zweiter Klick aendert nichts (schon)', async () => { + await speichere() + seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage' }) + const res = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body, { status: 'bestaetigt' }) + assert.equal(aw.docs('termineBookings').get('b_anfrage').status, 'bestaetigt') + await warteAufVersand() + assert.deepEqual(eintraege().map((m) => m.art), ['G02']) + assert.equal(sink.sent.length, 1) + assert.equal(sink.sent[0].to[0].address, 'max@example.com') + + const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } }) + assert.equal(zweiter.status, 200) + assert.deepEqual(zweiter.body, { status: 'bestaetigt', schon: true }) + await warteAufVersand() + assert.equal(sink.sent.length, 1, 'keine zweite Mail') +}) + +test('POST aktion: absagen braucht einen Grund (400), Betrieb sagt ab (abgesagt von betrieb), unbekannt und Sperre 404, falscher Zustand 409', async () => { + await speichere() + seedBuchung('b_fest') + seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' }) + const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } }) + + const ohneGrund = await aktion('b_fest', { aktion: 'absagen' }) + assert.equal(ohneGrund.status, 400) + assert.ok(ohneGrund.body.fehler.grund) + const unbekannteAktion = await aktion('b_fest', { aktion: 'loeschen' }) + assert.equal(unbekannteAktion.status, 400) + assert.ok(unbekannteAktion.body.fehler.aktion) + + const ok = await aktion('b_fest', { aktion: 'absagen', grund: 'Krank' }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.deepEqual(ok.body, { status: 'abgesagt' }) + const doc = aw.docs('termineBookings').get('b_fest') + assert.equal(doc.abgesagtVon, 'betrieb') + assert.equal(doc.absageGrund, 'Krank') + + assert.equal((await aktion('b_gibtsnicht', { aktion: 'annehmen' })).status, 404) + assert.equal((await aktion('b_sperre', { aktion: 'absagen', grund: 'x' })).status, 404, 'eine Sperre entsperrt man, man sagt sie nicht ab') + seedBuchung('b_fremd', { featureId: 'f_anderer_termine' }) + assert.equal((await aktion('b_fremd', { aktion: 'absagen', grund: 'x' })).status, 404) + const falsch = await aktion('b_fest', { aktion: 'annehmen' }) + assert.equal(falsch.status, 409) + assert.equal(falsch.body.code, 'nicht_moeglich') +}) + +// ---------------------------------------------------------------- Export + +async function rohExport(projectId = P, c = cookie) { + const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${projectId}`, { headers: { cookie: c } }) + const bytes = Buffer.from(await res.arrayBuffer()) + return { res, bytes, text: bytes.toString('utf8') } +} + +test('GET export.csv: UTF-8 mit BOM, Strichpunkt, Kopfzeile, nach Beginn sortiert, ohne Sperren und fremde Funktionen', async () => { + await speichere() + seedBuchung('b_zwei', { startsAt: '2026-10-14T08:00:00.000+00:00', endsAt: '2026-10-14T08:45:00.000+00:00', blockEndsAt: '2026-10-14T08:50:00.000+00:00', status: 'angefragt', gastName: 'Zweiter', gastEmail: 'zwei@example.com', gastTelefon: '', gastNachricht: 'Zeile 1\nZeile 2' }) + seedBuchung('b_eins', { gastName: 'Max "Maxe" Mustermann; Jr.' }) + seedBuchung('b_sperre', { quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '' }) + seedBuchung('b_fremd', { featureId: 'f_anderer_termine', gastName: 'Fremd' }) + + const { res, bytes, text } = await rohExport() + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8') + assert.match(res.headers.get('content-disposition'), /^attachment; filename="termine\.csv"$/) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.headers.get('x-content-type-options'), 'nosniff') + assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM') + const zeilen = text.replace(/^/, '').split('\r\n') + assert.equal(zeilen[0], 'Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht') + assert.equal(zeilen[1], `${buchungsNummer('b_eins')};Bestätigt;12.10.2026;10:00;Damenschnitt;Anna;"Max ""Maxe"" Mustermann; Jr.";max@example.com;0171 1234567;`) + assert.equal(zeilen[2], `${buchungsNummer('b_zwei')};Angefragt;14.10.2026;10:00;Damenschnitt;Anna;Zweiter;zwei@example.com;;"Zeile 1\nZeile 2"`, 'Zeilenumbruch in der Zelle gequotet') + assert.equal(zeilen[3], '', 'Zeilen enden mit CRLF') + assert.equal(zeilen.length, 4) + assert.ok(!text.includes('Pause') && !text.includes('Fremd')) +}) + +test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR) bekommen ein Hochkomma, harmlose bleiben', async () => { + await speichere() + const namen = ['=HYPERLINK("http://x")', '+49 171 1234567', '-1+1', '@SUM(A1)', ' =1+1', '\t=1+1', '\r=1+1', '\n+1', '\tText', 'Max Mustermann', 'a=b', 'Anna-Lena', ''] + namen.forEach((name, i) => seedBuchung(`b_${i}`, { + gastName: name, + gastNachricht: `=cmd|' /C calc'!A0 ${i}`, + leistungName: '=Leistung', + ressourceName: '@Ben', + startsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:00:00.000+00:00`, + endsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:45:00.000+00:00`, + blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`, + })) + const { text } = await rohExport() + const inhalt = text.replace(/^/, '') + assert.ok(inhalt.includes(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor') + assert.ok(inhalt.includes(";'+49 171 1234567;")) + assert.ok(inhalt.includes(";'-1+1;")) + assert.ok(inhalt.includes(";'@SUM(A1);")) + assert.ok(inhalt.includes(";' =1+1;")) + assert.ok(inhalt.includes(";'\t=1+1;")) + assert.ok(inhalt.includes(";\"'\r=1+1\";"), 'CR gequotet') + assert.ok(inhalt.includes(";\"'\n+1\";")) + assert.ok(inhalt.includes(";'\tText;")) + assert.ok(inhalt.includes(";Max Mustermann;")) + assert.ok(inhalt.includes(";a=b;")) + assert.ok(inhalt.includes(";Anna-Lena;")) + // Leistung, Person und Nachricht ebenso + assert.ok(inhalt.includes(";'=Leistung;'@Ben;")) + assert.ok(inhalt.includes(";'=cmd|' /C calc'!A0 0\r\n")) + // keine Zelle beginnt mehr mit einem der vier Zeichen + for (const zeile of inhalt.split('\r\n').slice(1)) { + for (const zelle of zeile.split(';')) assert.ok(!/^"?\s*[=+\-@]/.test(zelle), `Zelle ${JSON.stringify(zelle)}`) + } +}) + +// ---------------------------------------------------------------- Abgeschaltet (disabled) + +test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => { + const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' }) + await speichere(einstellung(), disabled) + aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' })) + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.equal(lesen.status, 200) + assert.equal(lesen.body.state, 'disabled') + assert.equal(lesen.body.einstellung.betrieb.name, 'Salon Müller') + assert.deepEqual(lesen.body.feeds, {}, 'die Kalender-Links sind bei einer abgeschalteten Funktion tot') + assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).body.buchungen.length, 1) + const export_ = await rohExport('p4') + assert.equal(export_.res.status, 200) + assert.ok(export_.text.includes('Max Mustermann')) + assert.equal((await send('GET', `/api/termine/zeiten?projectId=p4&leistung=${S_SOFORT}&tag=${TAG}`, { cookie })).status, 200) + + const schreiben = [ + ['PATCH', '/api/termine/einstellungen', { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung }], + ['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p4', vorlage: 'salon' }], + ['POST', '/api/termine/buchungen', { ...manuellBody(), projectId: 'p4' }], + ['POST', '/api/termine/buchungen/b_alt/aktion', { projectId: 'p4', aktion: 'absagen', grund: 'x' }], + ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p4' }], + ['DELETE', '/api/termine/buchungen/b_alt?projectId=p4'], + ] + for (const [method, pfad, body] of schreiben) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.code, 'abgeschaltet') + } + assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt') + assert.equal(aw.docs('termineConfig').get('f_p4_termine').version, 1) +}) + +// ---------------------------------------------------------------- Kundenansicht + +test('Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert; ohne Gastdaten im Protokoll', async () => { + await speichere() + seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, gastName: 'Geheim Gast', gastEmail: 'geheim@example.com' }) + const stand = await lade() + const k = kv() + + const patch = await send('PATCH', '/api/termine/einstellungen', { + ...k, + body: { projectId: P, version: stand.version, einstellung: { ...stand.einstellung, farbe: '#112233' } }, + }) + assert.equal(patch.status, 200, JSON.stringify(patch.body)) + assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'team:u_staff2') + + const feed = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { ...k, body: { projectId: P } }) + assert.equal(feed.status, 200) + const manuell = await send('POST', '/api/termine/buchungen', { ...k, body: manuellBody({ name: 'Privat Person' }) }) + assert.equal(manuell.status, 201, JSON.stringify(manuell.body)) + const sperre = await send('POST', '/api/termine/buchungen', { ...k, body: { projectId: P, art: 'sperre', ressource: R2, tag: '2026-10-13', von: '12:00', bis: '13:00' } }) + assert.equal(sperre.status, 201) + const aktion = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } }) + assert.equal(aktion.status, 200, JSON.stringify(aktion.body)) + const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } }) + assert.equal(zweiter.body.schon, true) + const weg = await send('DELETE', `/api/termine/buchungen/${sperre.body.id}?projectId=${P}`, k) + assert.equal(weg.status, 200) + const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { ...k, body: { projectId: P, vorlage: 'salon' } }) + assert.equal(vorlage.status, 409, 'es gibt schon Einstellungen: nichts protokolliert') + const fehlerhaft = await send('PATCH', '/api/termine/einstellungen', { ...k, body: { projectId: P, version: 99, einstellung: stand.einstellung } }) + assert.equal(fehlerhaft.status, 409) + + const eintraege_ = logEintraege().sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + assert.deepEqual(eintraege_.map((e) => [e.action, e.detail]), [ + ['termin_einstellungen', 'Einstellungen gespeichert'], + ['termin_einstellungen', 'Kalender-Link erneuert'], + ['termin_manuell', 'manuell'], + ['termin_manuell', 'sperre'], + ['termin_aktion', `ablehnen ${buchungsNummer('b_anfrage')}`], + ['termin_aktion', `entsperren ${buchungsNummer(sperre.body.id)}`], + ]) + assert.ok(eintraege_.every((e) => e.customerId === KUNDE && e.staffName === 'Andrej' && e.staffUserId === 'u_staff2')) + const alles = JSON.stringify(eintraege_) + for (const geheim of ['Geheim Gast', 'geheim@example.com', 'Privat Person', '06301 555']) assert.ok(!alles.includes(geheim), geheim) +}) + +test('Kundenansicht ohne Header oder mit fremder Kennung: Schreiben 409 view_replaced; Lesen geht; Kunde selbst schreibt ohne Protokoll', async () => { + await speichere() + const stand = await lade() + const body = { projectId: P, version: stand.version, einstellung: stand.einstellung } + const ohneHeader = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), body }) + assert.equal(ohneHeader.status, 409) + assert.equal(ohneHeader.body.code, 'view_replaced') + const fremd = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) }, body }) + assert.equal(fremd.body.code, 'view_replaced') + assert.equal((await send('GET', einstellungenUrl(), { cookie: kundenansichtCookie() })).status, 200) + assert.equal((await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie: kundenansichtCookie() })).status, 200) + assert.equal(aw.docs('termineConfig').get(FEATURE).version, 1) + + const kunde = await send('PATCH', '/api/termine/einstellungen', { cookie, body }) + assert.equal(kunde.status, 200) + assert.equal(logEintraege().length, 0, 'nur die Kundenansicht wird protokolliert') +}) + +// ---------------------------------------------------------------- Limit und Fehler + +test('Schreib-Limit: 120 Aenderungen je Kunde und Stunde, die 121. ist 429; Lesen zaehlt nicht; anderer Kunde und naechste Stunde frei', async () => { + await speichere() + let jetzt = JETZT.getTime() + setzeUhrFuerTests(() => new Date(jetzt)) + for (let i = 0; i < 40; i += 1) assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200) + for (let i = 1; i <= 120; i += 1) { + const res = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie }) + assert.equal(res.status, 404, `Anfrage ${i}`) + } + const zuViele = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie }) + assert.equal(zuViele.status, 429) + assert.match(zuViele.body.error, /Zu viele Änderungen/) + assert.equal((await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() })).status, 429, 'alle Schreib-Routen teilen das Limit') + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Lesen bleibt frei') + + // ein anderer Kunde hat sein eigenes Limit + assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=p6`, { cookie: customerCookie('c2') })).status, 409, 'nicht 429 (keine Einstellung)') + // eine Stunde spaeter + jetzt += 61 * 60 * 1000 + assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })).status, 404) +}) + +test('Fehler von Appwrite verlassen den Server nie als Text: 500 "Interner Serverfehler"', async () => { + await speichere() + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/termineConfig\/documents\//.test(String(url)) && (optionen?.method ?? 'GET') === 'GET') { + return new Response(JSON.stringify({ message: 'geheimer Appwrite-Text', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } }) + } + return echt(url, optionen) + } + try { + const res = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(res.status, 500) + assert.deepEqual(res.body, { error: 'Interner Serverfehler' }) + } finally { + globalThis.fetch = echt + } +})