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 { einbauSnippet } from '../services/termine/einbauanleitung.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: , 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 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 } 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). In `ordered` liefert sie bestellung und * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). 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 const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung 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: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null, bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {}, 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 .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=` (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