Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 19:29:12 +00:00
1 parent 31aa80affd
commit 250fc0c4df
3 files changed
+613 -11

No files matched your search

+77 -8
View File
@@ -1,8 +1,11 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { aboMemo } from '../services/aboStatus.js'
import { analyseHosts } from '../services/analyse.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { featureConfig } from '../services/featureRegistry.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
@@ -11,7 +14,8 @@ import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBe
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 { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } from '../services/termine/fragebogen.js'
import { kontextFuerPortal, 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'
@@ -24,6 +28,8 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
*
* 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 }).
* 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).
@@ -155,8 +161,14 @@ const projectIdOf = (req) => {
/**
* 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, nur GET /einstellungen): Lesen 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 nie etwas angelegt. Projekt (403)
* und Schalter (409) gelten wie sonst. Die Schreibregeln des Entwurfs folgen mit C4b: bis dahin behält jede Route mit
* `schreiben` die Schranke active, auch wenn `entwurf` gesetzt ist.
*/
const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => {
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)
@@ -169,6 +181,10 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next
req.projectId = projectId
if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
const feature = await getFeatureDoc(customerId, projectId, 'termine')
if (entwurf && !schreiben) {
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')
@@ -176,6 +192,15 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next
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)}`)
@@ -220,20 +245,50 @@ router.use((_req, res, next) => {
next()
})
router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => {
/** 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) {
// nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten)
console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
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: ctx.feature.state,
abgeschaltet: ctx.feature.state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
state,
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
aktivSeit: ctx.feature.activatedAt || null,
einstellung: e ? fuerKunde(e) : null,
version: ctx.version,
vorlagen: vorlagenListe(),
feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {},
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: { snippet: einbauSnippet(ctx) },
anfragenOffen: await zaehleAnfragen(ctx.feature.$id),
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
})
}))
@@ -250,6 +305,20 @@ async function betriebAusKonto(customerId) {
return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' }
}
/** Adresse der Live-Website des Projekts (nur aus liveDomain, nie die Vorschau-Adresse <subdomain>.project.webklar.com), sonst ''. */
function liveWebsite(project) {
if (!project?.liveDomain) return ''
const vorschau = project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
const host = analyseHosts(project).find((h) => h !== vorschau)
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) }
}
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')