Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31aa80affd
commit
250fc0c4df
3 files changed
+613
-11
No files matched your search
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user