Files
Webklar-Kundenbereich/server/routes/termine.js
T
ClaudeandClaude Sonnet 5.5 29a38a9592 Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen
POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).

PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.

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

768 lines
40 KiB
JavaScript

import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { aboMemo, laeuft } from '../services/aboStatus.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { featureConfig } from '../services/featureRegistry.js'
import { aboMeldung } from '../services/featureOrder.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, ensureFeatureDoc, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen } 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.
* Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne
* Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). PATCH /einstellungen
* schreibt den Entwurf nach eigenen Regeln (off und active ja; ordered und setup nur in der Kundenansicht; disabled nie; in off
* nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. POST /einstellungen/vorlage
* legt den Entwurf aus einer Branche an (oder ersetzt ihn, solange nichts bestellt ist) und schreibt dabei den Fragebogen mit;
* PUT /fragebogen speichert nur den Fragebogen (nie die Version der Einstellung) und nie in der Kundenansicht.
* 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: <erster Text>, fehler?: { <feld|_>: 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 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.',
abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.',
abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.',
gesperrt: 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.',
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.',
branche: 'Bitte wähle eine Branche aus der Liste.',
gesundheitsbereich: 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.',
betrieb: 'Die Betriebsangaben sind ungültig.',
vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.',
nichtErsetzbar: 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.',
kundenansichtBestaetigen: 'In der Kundenansicht kann nichts bestätigt werden.',
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.',
keineBuchung: 'Diesen Termin 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, ebenso das 503 der
* Abo-Prüfung des Entwurfs (`abo_pruefung`: "später noch einmal"); 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'
const durchgelassen = (status >= 400 && status < 500) || (status === 503 && err?.code === 'abo_pruefung')
if (!eigen || !durchgelassen) {
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
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetTermineRoutesForTests() {
limiter.reset()
uhr = () => new Date()
}
// ---------------------------------------------------------------- Projekt und Funktion
/**
* Was die Abschaltung dem Kunden sagt: Grund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt (disabledAt) und der Berliner
* Tag, ab dem die Daten gelöscht werden dürfen (loeschungAm, 30 Tage nach disabledAt, termine/frist.js; null ohne lesbaren Zeitpunkt).
* Diese Route gehört nur zum Terminplaner, deshalb gibt es das Löschdatum hier immer (R105: bei News nie, siehe siteFeatures.featureView).
*/
const abgeschaltetAnsicht = (feature) => ({
grund: feature.disabledReason || null,
seit: feature.disabledAt || null,
loeschungAm: loeschTag(feature.disabledAt),
})
/** Antwort auf einen Schreibversuch bei abgeschalteter Funktion (409, Code `abgeschaltet`): je Grund ein Satz, mit Löschdatum, wenn es eines gibt. */
function abgeschaltetMeldung(feature) {
const grund = feature.disabledReason
const satz = grund === 'abo_ende' ? MELDUNG.abgeschaltetAboEnde : grund === 'abo_ende_vor_einrichtung' ? MELDUNG.abgeschaltetVorEinrichtung : MELDUNG.abgeschaltet
const ende = loeschzeit(feature.disabledAt)
return ende ? `${satz} Ab dem ${datumKurz(ende)} löschen wir die Daten.` : satz
}
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
/**
* Das Abo konnte nicht gelesen werden: nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten).
* Der Aufrufer entscheidet, was dann gilt (Vorschau: "nicht geprüft", Entwurf schreiben: 503).
*/
const logAboFehler = (err) => console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
/**
* Schreibregeln des Entwurfs (PATCH /einstellungen), nach dem Zustand des Funktionsdokuments (`feature`, null = kein Dokument = off):
* active ja (die Einstellung läuft; der Abo-Abgleich beendet sie, nicht das Speichern)
* off ja, aber nur mit laufendem Wartungs-Abo (laeuft, nicht bestellbar: eine Kündigung oder offene Zahlung sperrt nur die
* Bestellung, Gate (h), nicht das Speichern des Entwurfs). Ohne Abo 409 `kein_abo`, bei Lesefehler 503 `abo_pruefung`.
* ordered, setup nur in der Kundenansicht (Mitarbeiter); der Kunde selbst bekommt 409 `gesperrt`: Änderungen laufen über den Chat
* disabled 409 `abgeschaltet` (Text von E11), auch in der Kundenansicht
* Ein unbekannter Zustand gilt als nicht aktiv (409). Liest nichts außer dem Abo und schreibt nichts.
*/
async function pruefeEntwurfSchreiben(req, customerId, feature) {
const state = feature ? feature.state : 'off'
if (state === 'active') return
if (state === 'disabled') throw stop(409, abgeschaltetMeldung(feature), 'abgeschaltet')
if (state === 'ordered' || state === 'setup') {
if (isKundenansicht(req.session)) return
throw stop(409, MELDUNG.gesperrt, 'gesperrt')
}
if (state !== 'off') throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
const register = featureConfig('termine')
let laufend
try {
laufend = await laeuft(customerId)
} catch (err) {
logAboFehler(err)
throw stop(503, aboMeldung(register, 'unbekannt'), 'abo_pruefung')
}
if (!laufend) throw stop(409, aboMeldung(register, 'kein_abo'), 'kein_abo')
}
/**
* 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).
*
* entwurf (TP2): Zugriff auf den Entwurf in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, disabled) über
* kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller `off` ohne
* Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und hier nie etwas angelegt (das macht der Handler
* mit ensureFeatureDoc, erst nach der Eingabeprüfung). Projekt (403) und Schalter (409) gelten wie sonst. Ohne `schreiben`
* (GET) liest jeder Zustand; mit `schreiben` gelten die Regeln von pruefeEntwurfSchreiben. Öffentlich wird der Entwurf nie:
* die öffentliche Schnittstelle liest nur Funktionen im Zustand active (kontextPerSiteKey).
*/
const withTermine = ({ schreiben = false, entwurf = 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')
if (entwurf) {
if (schreiben) await pruefeEntwurfSchreiben(req, customerId, feature)
req.ctx = feature ? await kontextFuerPortal(feature) : virtuellerKontext(customerId, projectId, req.project)
return next()
}
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, abgeschaltetMeldung(ctx.feature), 'abgeschaltet')
req.ctx = ctx
return next()
})
/** Kontext ohne Funktionsdokument: Zustand `off`, nichts gespeichert (ohne $id: das Dokument gibt es nicht und wird hier nie angelegt). */
const virtuellerKontext = (customerId, projectId, project) => ({
feature: { customerId, projectId, featureKey: 'termine', state: 'off', enabled: false },
project,
einstellung: null,
version: 0,
fragebogen: leerFragebogen(),
})
/** 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 [
`<div data-webklar-termine="${esc(ctx.feature.siteKey)}" data-api="${esc(config.publicBaseUrl)}" data-modus="inline">`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(config.publicBaseUrl)}/embed/termine.js" defer></script>`,
].join('\n')
}
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
/** Das Abo für die Bestell-Vorschau: { ok, grund? } oder null, wenn es nicht lesbar war (dann gilt der Abo-Punkt "nicht geprüft"). Nie ein Fehler nach außen. */
async function aboFuerVorschau(customerId) {
try {
return await aboMemo().bestellbar(customerId)
} catch (err) {
logAboFehler(err)
return null
}
}
/**
* Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true).
* Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links
* gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active).
*/
router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => {
const { ctx } = req
const e = ctx.einstellung
const state = ctx.feature.state
const laeuft = state === 'active' || state === 'disabled'
const customerId = getSessionCustomerId(req)
const register = featureConfig('termine')
const abo = state === 'off' ? await aboFuerVorschau(customerId) : null
res.json({
state,
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
aktivSeit: ctx.feature.activatedAt || null,
einstellung: e ? fuerKunde(e) : null,
version: ctx.version,
vorlagen: vorlagenListe(),
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
fragebogen: ctx.fragebogen,
kundenansicht: isKundenansicht(req.session),
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
abo,
offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null,
bestellung: state === 'off' ? await bestellungAnsicht(register) : null,
feeds: e && state === 'active' ? feedLinks(ctx) : {},
siteKey: ctx.feature.siteKey || null,
einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {},
anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0,
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 : '' }
}
/** Adresse der Live-Website des Projekts (liveHost: nur aus liveDomain, nie die Vorschau-Adresse <subdomain>.project.webklar.com), sonst ''. */
function liveWebsite(project) {
const host = liveHost(project)
return host ? `https://${host}` : ''
}
/** Vorschlag für die Betriebsangaben des Fragebogens, solange es noch keine Einstellung gibt: Konto (betriebAusKonto) und Live-Website. */
async function betriebVorschlag(customerId, project) {
const { name, adresse, email, telefon } = await betriebAusKonto(customerId)
return { name, adresse, email, telefon, websiteUrl: liveWebsite(project) }
}
// Betriebsfelder, die der Kunde beim Anlegen der Vorlage mitschicken darf (Anschrift und Telefon u. a. aus dem Konto vorbelegt)
const BETRIEB_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl']
/**
* Die Betriebsfelder aus dem Rumpf der Vorlage-Route: nur die fünf bekannten, nur nichtleere Texte (ein leeres Feld lässt den
* Wert aus dem Konto stehen). Kein Objekt oder ein Feld, das kein Text ist => 400. Prüft nur die Form; Länge, Adresse und
* Mail-Format prüft die Einstellung selbst (ausVorlage).
*/
function betriebFelder(eingabe) {
if (eingabe === undefined || eingabe === null) return {}
if (!istObjekt(eingabe)) throw stop(400, MELDUNG.betrieb, undefined, 'betrieb')
const felder = {}
for (const feld of BETRIEB_FELDER) {
const wert = eingabe[feld]
if (wert === undefined || wert === null) continue
if (typeof wert !== 'string') throw stop(400, MELDUNG.betrieb, undefined, `betrieb.${feld}`)
if (wert.trim() !== '') felder[feld] = wert.trim()
}
return felder
}
/**
* Entwurf aus einer Vorlage anlegen (TP2: aus einer Branche, auch als Ersatz für den bestehenden Entwurf).
* { projectId, branche } Branche laut BRANCHEN; unbekannt => 400; Gesundheitsbereich => 400 `gesundheitsbereich` (harter
* Ausschluss, nichts wird angelegt, auch kein Funktionsdokument); die Branche steht danach im Fragebogen
* { projectId, vorlage } wie in TP1: Vorlage nach Schlüssel, der Fragebogen bleibt unberührt
* betrieb { name, adresse, telefon, email, websiteUrl }: überschreibt nur mit nichtleeren Werten das Konto
* ersetzen: true + version eine bestehende Einstellung ersetzen: nur im Zustand `off` und nur mit der gelesenen Version (sonst 409
* `EINSTELLUNG_VERALTET`); neue IDs und Kalender-Token, Fragebogen neu (Branche, keine bestätigten
* Schritte, Preise und Bundesland ungeprüft), die Einbauwünsche bleiben. Einstellung und Fragebogen
* werden in einem Vorgang unter der Sperre geschrieben (speichereEinstellung, Option fragebogen).
* Ohne `ersetzen` gilt wie in TP1: gibt es schon Einstellungen, ist es 409 `einstellung_vorhanden`.
*/
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const body = istObjekt(req.body) ? req.body : {}
let key
let brancheKey = null
if (body.branche !== undefined && body.branche !== null) {
const branche = typeof body.branche === 'string' ? BRANCHEN.find((b) => b.key === body.branche) : undefined
if (!branche) throw stop(400, MELDUNG.branche, undefined, 'branche')
if (branche.gesundheit) throw stop(400, MELDUNG.gesundheitsbereich, 'gesundheitsbereich', 'branche')
key = branche.vorlage
brancheKey = branche.key
} else {
key = body.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
}
const eigene = betriebFelder(body.betrieb)
const ersetzen = body.ersetzen === true
if (req.ctx.einstellung && !ersetzen) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
if (ersetzen) {
if (!Number.isInteger(body.version) || body.version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
if (req.ctx.feature.state !== 'off') throw stop(409, MELDUNG.nichtErsetzbar, 'nicht_ersetzbar')
}
const customerId = getSessionCustomerId(req)
const betrieb = { ...(await betriebAusKonto(customerId)), ...eigene }
const now = uhr()
const baue = () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}
// Ohne Funktionsdokument legt erst dieser Handler es an, nach der Eingabeprüfung (eine abgelehnte Vorlage hinterlässt nichts)
let feature = req.ctx.feature
if (!feature.$id) {
baue()
feature = await ensureFeatureDoc(customerId, req.projectId, 'termine')
}
// Branche oder Ersetzen: der Fragebogen beginnt neu, nur die Einbauwünsche bleiben (aus dem Stand unter der Sperre)
const fragebogen = brancheKey !== null || ersetzen
? (bisher) => ({ ...leerFragebogen(), branche: brancheKey ?? key, einbau: bisher.einbau })
: undefined
let gespeichert
try {
// ohne ersetzen version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(feature, baue, { version: ersetzen ? body.version : 0, by: von(req), now, fragebogen })
} catch (err) {
throw err?.code === 'EINSTELLUNG_VERALTET' && !ersetzen ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
}
await logIfKundenansicht(req, 'termin_einstellungen', ersetzen ? `Vorlage ${key} ersetzt` : `Vorlage ${key}`)
res.status(201).json({
einstellung: fuerKunde(gespeichert.einstellung),
version: gespeichert.version,
fehlt: fehlendePflichtangaben(gespeichert.einstellung),
fragebogen: gespeichert.fragebogen ?? req.ctx.fragebogen,
})
}))
/** Schritte bestätigt nur der Kunde selbst: jede Sitzung der Kundenansicht (viewAs) bekommt 403, vor jeder anderen Prüfung. */
const nichtInKundenansicht = route(async (req, _res, next) => {
if (req.session?.viewAs) throw stop(403, MELDUNG.kundenansichtBestaetigen)
return next()
})
/**
* Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht:
* Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal).
* Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur
* fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern.
* Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag.
*/
router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const geprueft = normalisiereFragebogen(req.body?.fragebogen)
if (geprueft.fehler) throw Object.assign(new Error(Object.values(geprueft.fehler)[0]), { status: 400, fehler: geprueft.fehler })
const { einstellung, feature } = mitEinstellung(req)
let gespeichert
try {
gespeichert = await speichereFragebogen(feature, () => geprueft.fragebogen, { by: von(req), now: uhr() })
} catch (err) {
throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err
}
const abo = feature.state === 'off' ? await aboFuerVorschau(getSessionCustomerId(req)) : null
const offen = feature.state === 'off' ? bestellpruefung({ einstellung, fragebogen: gespeichert.fragebogen, feature: featureConfig('termine'), abo }) : null
res.json({ fragebogen: gespeichert.fragebogen, offen })
}))
/**
* Einstellungen speichern (Version wie beim Lesen, optimistisch gesperrt). Der Entwurf (alles außer active) darf unvollständig
* gespeichert werden: es entfallen nur die Pflichtangaben für Gäste (Telefon, Anschrift), alles andere prüft er wie active; die
* Antwort nennt sie in `fehlt`. In active sind sie Pflicht (400). Ohne Funktionsdokument legt erst dieser Handler es an, nach der
* Eingabeprüfung (ein abgelehnter Entwurf hinterlässt nichts).
*/
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true, entwurf: 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 entwurf = req.ctx.feature.state !== 'active'
const pruefe = (bisher, kuenftigeTermine) => {
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, entwurf, now })
if (ergebnis.fehler) {
throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler })
}
return ergebnis.einstellung
}
let feature = req.ctx.feature
if (!feature.$id) {
pruefe(null, null)
feature = await ensureFeatureDoc(getSessionCustomerId(req), req.projectId, 'termine')
}
// 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
return pruefe(bisher, kuenftigeTermine)
}, { version, by: von(req), now })
await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert')
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
}))
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
/**
* Buchungen im Zeitraum `von` bis `bis` (Berliner Tage 'JJJJ-MM-TT', beide Tage eingeschlossen, beide Pflicht), nach Beginn
* sortiert, mit Sperren. Zeitraum: `bis` darf höchstens 62 Tage nach `von` liegen (bis ≤ von + 62 Tage, also bis zu 63
* Kalendertage) und nicht davor. `status` (optional) ist eine Liste bekannter Status, durch Komma getrennt; ohne Angabe
* kommen alle Status.
*/
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)) })
}))
/**
* Freie Startzeiten eines Tages für den Betrieb (Termin eintragen, Verschieben): ohne Vorlauf und Horizont, nie in der
* Vergangenheit. `leistung` und `ressource` (Vorgabe 'egal') wählen, was gesucht wird.
* Mit `ohne=<Buchungs-ID>` (Verschieben) gelten Leistung, Dauer und Person der Buchung, genau wie beim Verschieben selbst
* (ablauf.js verschiebeZeiten, Ruling R42); `leistung` und `ressource` aus der Abfrage werden dann ignoriert. Unbekannte
* Buchung, Buchung einer anderen Funktion oder Sperre => 404.
*/
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
const ctx = mitEinstellung(req)
const { leistung, ressource = 'egal', tag, ohne } = req.query
const now = uhr()
if (ohne !== undefined) {
if (typeof ohne !== 'string' || !ID_RE.test(ohne)) throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
const buchung = await hole(ohne)
if (!buchung || buchung.featureId !== ctx.feature.$id || buchung.quelle === 'sperre') throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
const { zeiten } = await verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln: true })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}
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')
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
const zeiten = freieZeiten({
einstellung: ctx.einstellung,
leistungId: leistung,
ressourceId: ressource,
tag,
belegung: belegungAus(amTag, now),
now,
ohneRegeln: true,
})
return 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
}
/** Eine Datenzeile der CSV-Datei (ohne Zeilenende). */
function csvZeile(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,
].map(csvZelle).join(';')
}
/** Schreibt einen Teil der Antwort; ist der Puffer voll, wartet es auf 'drain' (oder das Ende der Verbindung), damit ein langsamer Browser den Speicher nicht füllt. */
function schreibe(res, text) {
if (text === '' || res.destroyed || res.write(text)) return Promise.resolve()
return new Promise((resolve) => {
const weiter = () => {
res.off('drain', weiter)
res.off('close', weiter)
resolve()
}
res.once('drain', weiter)
res.once('close', weiter)
})
}
/**
* CSV-Export, seitenweise gestreamt: je Seite höchstens EXPORT_SEITE Zeilen (nach Beginn, Cursor = letzte Zeile der
* ungefilterten Seite), Sperren werden je Seite herausgefiltert. Die erste Seite wird gelesen, bevor ein Header geht: ein Fehler
* dort ist ein normales JSON-500. Danach bricht ein Fehler die Verbindung ab (res.destroy), damit der Browser einen abgebrochenen
* Download statt einer still gekürzten Datei zeigt. Löscht der Tagesjob zwischen zwei Seiten genau die Buchung, die als Cursor
* dient (selten), lehnt Appwrite den Cursor ab: der Export bricht ab und wird neu geladen.
*/
router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => {
const featureId = req.ctx.feature.$id
let seite = await exportSeite(featureId)
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="termine.csv"',
'X-Content-Type-Options': 'nosniff',
})
try {
await schreibe(res, `\uFEFF${CSV_KOPF.map(csvZelle).join(';')}\r\n`)
for (;;) {
await schreibe(res, seite.filter((b) => b.quelle !== 'sperre').map((b) => `${csvZeile(b)}\r\n`).join(''))
if (seite.length < EXPORT_SEITE || res.destroyed) break
seite = await exportSeite(featureId, { nach: seite.at(-1).$id })
}
res.end()
} catch (err) {
// nach dem ersten Byte geht kein JSON mehr; ins Log nur Art und Status, nie den Text (er könnte Gastdaten enthalten)
console.error(`[termine] Export abgebrochen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.trim())
res.destroy(err)
}
}))
export default router