Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen, kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung nimmt eine Funktion unter der Sperre. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e8fd33713
commit
e3e198fb1a
10 files changed
+1766
-40
No files matched your search
@@ -0,0 +1,474 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { analysePrivacyData } from '../services/analysePrivacy.js'
|
||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
import { imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
|
||||
import { zustellFehler } from '../services/termine/outbox.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { belegungAus, freieZeiten } from '../services/termine/slots.js'
|
||||
import { buchungsNummer } from '../services/termine/token.js'
|
||||
import { VORLAGEN, ausVorlage, vorlagenListe } from '../services/termine/vorlagen.js'
|
||||
import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine/zeit.js'
|
||||
|
||||
/**
|
||||
* Kunden-Schnittstelle des Terminplaners: /api/termine (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 9).
|
||||
*
|
||||
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder
|
||||
* `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409.
|
||||
* Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in
|
||||
* services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion,
|
||||
* termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail).
|
||||
*
|
||||
* Fehler: { error: <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 HOUR_MS = 60 * 60 * 1000
|
||||
const MAX_WRITES_PER_HOUR = 120
|
||||
const MAX_TAGE = 62
|
||||
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
|
||||
const STATUS_TEXT = {
|
||||
gehalten: 'Reserviert',
|
||||
angefragt: 'Angefragt',
|
||||
bestaetigt: 'Bestätigt',
|
||||
erledigt: 'Erledigt',
|
||||
nichterschienen: 'Nicht erschienen',
|
||||
abgesagt: 'Abgesagt',
|
||||
abgelehnt: 'Abgelehnt',
|
||||
verfallen: 'Verfallen',
|
||||
}
|
||||
const CSV_KOPF = ['Nummer', 'Status', 'Datum', 'Uhrzeit', 'Leistung', 'Person/Platz', 'Name', 'E-Mail', 'Telefon', 'Nachricht']
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
const MELDUNG = {
|
||||
projekt: 'Bitte wähle ein Projekt.',
|
||||
fremd: 'Kein Zugriff auf dieses Projekt.',
|
||||
nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.',
|
||||
nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.',
|
||||
abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.',
|
||||
keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.',
|
||||
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
|
||||
vorlage: 'Bitte wähle eine Vorlage aus der Liste.',
|
||||
vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.',
|
||||
version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.',
|
||||
art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.',
|
||||
zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).',
|
||||
zeitraumLang: `Der Zeitraum darf höchstens ${MAX_TAGE} Tage umfassen.`,
|
||||
zeitraumUmgekehrt: 'Das Ende darf nicht vor dem Beginn liegen.',
|
||||
status: 'Ungültiger Status.',
|
||||
tag: 'Bitte ein Datum auswählen.',
|
||||
leistung: 'Bitte eine Leistung auswählen.',
|
||||
ressource: 'Unbekannte Person oder unbekannter Platz.',
|
||||
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
|
||||
ungueltig: 'Ungültige Eingabe.',
|
||||
}
|
||||
|
||||
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||
let uhr = () => new Date()
|
||||
export function setzeUhrFuerTests(neueUhr) {
|
||||
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Fehler
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */
|
||||
const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) })
|
||||
|
||||
/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 500 ohne Rohtext. */
|
||||
function fail(res, err) {
|
||||
const status = Number.isInteger(err?.status) ? err.status : 500
|
||||
const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {}
|
||||
const eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET'
|
||||
if (!eigen || status < 400 || status >= 500) {
|
||||
console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
return res.status(500).json({ error: 'Interner Serverfehler' })
|
||||
}
|
||||
const body = { error: Object.values(texte)[0] ?? String(err.message) }
|
||||
if (Object.keys(texte).length > 0) body.fehler = texte
|
||||
if (typeof err.code === 'string') body.code = err.code
|
||||
return res.status(status).json(body)
|
||||
}
|
||||
|
||||
/** Handler mit Fehlerbehandlung (Express 4 fängt async-Fehler nicht selbst). */
|
||||
const route = (fn) => async (req, res, next) => {
|
||||
try {
|
||||
await fn(req, res, next)
|
||||
} catch (err) {
|
||||
fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Schreib-Limit
|
||||
|
||||
// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE
|
||||
const recentWrites = new Map()
|
||||
function writeRateLimited(customerId) {
|
||||
const now = uhr().getTime()
|
||||
for (const [key, times] of recentWrites) {
|
||||
const live = times.filter((t) => now - t < HOUR_MS)
|
||||
if (live.length === 0) recentWrites.delete(key)
|
||||
else if (live.length !== times.length) recentWrites.set(key, live)
|
||||
}
|
||||
const list = recentWrites.get(customerId) || []
|
||||
list.push(now)
|
||||
recentWrites.set(customerId, list)
|
||||
return list.length > MAX_WRITES_PER_HOUR
|
||||
}
|
||||
|
||||
const limitWrites = route((req, res, next) => {
|
||||
if (writeRateLimited(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuViele })
|
||||
return next()
|
||||
})
|
||||
|
||||
export function resetTermineRoutesForTests() {
|
||||
recentWrites.clear()
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Projekt und Funktion
|
||||
|
||||
const projectIdOf = (req) => {
|
||||
const raw = req.query.projectId ?? req.body?.projectId
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active.
|
||||
* Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409).
|
||||
*/
|
||||
const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => {
|
||||
const projectId = projectIdOf(req)
|
||||
if (!projectId) throw stop(400, MELDUNG.projekt, undefined, 'projectId')
|
||||
const customerId = getSessionCustomerId(req)
|
||||
try {
|
||||
req.project = await assertOwnProject(customerId, projectId)
|
||||
} catch (err) {
|
||||
// auch eine unbrauchbare ID (Appwrite 400) ist kein eigenes Projekt
|
||||
throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err
|
||||
}
|
||||
req.projectId = projectId
|
||||
if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
|
||||
const feature = await getFeatureDoc(customerId, projectId, 'termine')
|
||||
const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null
|
||||
if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
|
||||
if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet')
|
||||
req.ctx = ctx
|
||||
return next()
|
||||
})
|
||||
|
||||
/** Wer ändert? Für updatedBy der Einstellung (Kundenansicht: der Mitarbeiter, sonst der Kunde). */
|
||||
const von = (req) => (isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : `kunde:${getSessionCustomerId(req)}`)
|
||||
|
||||
/** Buchungen, Zeiten und Aktionen brauchen gespeicherte Einstellungen (Betriebsangaben). */
|
||||
function mitEinstellung(req) {
|
||||
if (!req.ctx.einstellung) throw stop(409, MELDUNG.keineEinstellung, 'keine_einstellung')
|
||||
return req.ctx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einstellungen
|
||||
|
||||
/** Einstellung für den Browser: ohne Feed-Token (die Links stehen in `feeds`, neue Token vergibt nur der Server). */
|
||||
const fuerKunde = (e) => ({ ...e, ressourcen: (e.ressourcen || []).map(({ feedToken: _geheim, ...rest }) => rest) })
|
||||
|
||||
const feedUrl = (siteKey, token) => `${config.publicBaseUrl}/api/public/termine/v1/kalender/${siteKey}/${token}.ics`
|
||||
|
||||
/** { [ressourceId]: Kalender-Link } aller Personen und Plätze. */
|
||||
function feedLinks(ctx) {
|
||||
const links = {}
|
||||
for (const r of ctx.einstellung?.ressourcen || []) {
|
||||
if (typeof r?.id === 'string' && typeof r.feedToken === 'string') links[r.id] = feedUrl(ctx.feature.siteKey, r.feedToken)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z])
|
||||
|
||||
/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */
|
||||
function einbauSnippet(ctx) {
|
||||
const telefon = ctx.einstellung?.betrieb?.telefon
|
||||
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
|
||||
return [
|
||||
`<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()
|
||||
})
|
||||
|
||||
router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => {
|
||||
const { ctx } = req
|
||||
const e = ctx.einstellung
|
||||
res.json({
|
||||
state: ctx.feature.state,
|
||||
aktivSeit: ctx.feature.activatedAt || null,
|
||||
einstellung: e ? fuerKunde(e) : null,
|
||||
version: ctx.version,
|
||||
vorlagen: vorlagenListe(),
|
||||
feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {},
|
||||
siteKey: ctx.feature.siteKey || null,
|
||||
einbau: { snippet: einbauSnippet(ctx) },
|
||||
anfragenOffen: await zaehleAnfragen(ctx.feature.$id),
|
||||
fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar
|
||||
})
|
||||
}))
|
||||
|
||||
/** Betriebsangaben aus dem Kundenkonto für eine Vorlage: Name/Firma, Anschrift, E-Mail (analysePrivacyData) und Telefon. */
|
||||
async function betriebAusKonto(customerId) {
|
||||
let kunde = null
|
||||
try {
|
||||
kunde = await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) throw err
|
||||
}
|
||||
const daten = analysePrivacyData(kunde)
|
||||
return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' }
|
||||
}
|
||||
|
||||
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const key = req.body?.vorlage
|
||||
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
|
||||
if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
|
||||
|
||||
const betrieb = await betriebAusKonto(getSessionCustomerId(req))
|
||||
const now = uhr()
|
||||
let gespeichert
|
||||
try {
|
||||
// version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
|
||||
gespeichert = await speichereEinstellung(req.ctx.feature, () => {
|
||||
try {
|
||||
return ausVorlage(key, betrieb, now)
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
|
||||
}
|
||||
}, { version: 0, by: von(req), now })
|
||||
} catch (err) {
|
||||
throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
|
||||
}
|
||||
await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`)
|
||||
res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
|
||||
}))
|
||||
|
||||
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const { version, einstellung: eingabe } = req.body ?? {}
|
||||
if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
|
||||
const now = uhr()
|
||||
const feature = req.ctx.feature
|
||||
|
||||
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
|
||||
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
|
||||
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
|
||||
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now })
|
||||
if (ergebnis.fehler) {
|
||||
throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler })
|
||||
}
|
||||
return ergebnis.einstellung
|
||||
}, { version, by: von(req), now })
|
||||
|
||||
await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert')
|
||||
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version })
|
||||
}))
|
||||
|
||||
router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const ressourceId = String(req.params.id)
|
||||
const now = uhr()
|
||||
// Baut auf dem aktuellen Stand auf (version null) und ersetzt nur das Token dieser einen Person/dieses Platzes
|
||||
const gespeichert = await speichereEinstellung(req.ctx.feature, (bisher) => {
|
||||
const ressourcen = Array.isArray(bisher?.ressourcen) ? bisher.ressourcen : []
|
||||
if (!ressourcen.some((r) => r?.id === ressourceId)) throw stop(404, MELDUNG.keineRessource, 'unbekannt')
|
||||
return { ...bisher, ressourcen: ressourcen.map((r) => (r.id === ressourceId ? { ...r, feedToken: neuerFeedToken() } : r)) }
|
||||
}, { version: null, by: von(req), now })
|
||||
|
||||
const token = gespeichert.einstellung.ressourcen.find((r) => r.id === ressourceId).feedToken
|
||||
await logIfKundenansicht(req, 'termin_einstellungen', 'Kalender-Link erneuert')
|
||||
// version: die Änderung zählt die Version hoch, die offene Einstellungsseite braucht die neue für ihr nächstes Speichern
|
||||
res.json({ url: feedUrl(req.ctx.feature.siteKey, token), version: gespeichert.version })
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Buchungen lesen
|
||||
|
||||
const iso = (wert) => {
|
||||
const zeit = wert ? new Date(wert) : null
|
||||
return zeit && !Number.isNaN(zeit.getTime()) ? zeit.toISOString() : null
|
||||
}
|
||||
|
||||
/** Eine Buchung für das Portal. datum = Berliner Tag 'JJJJ-MM-TT', zeit = 'HH:MM' (Berliner Wanduhr). */
|
||||
function buchungJson(b, zustellFehlerIds) {
|
||||
const start = new Date(b.startsAt)
|
||||
const ende = new Date(b.endsAt)
|
||||
const istSperre = b.quelle === 'sperre'
|
||||
const { tag, uhrzeit: zeit } = ausUtc(start)
|
||||
const leistung = istSperre ? '' : b.leistungName || ''
|
||||
return {
|
||||
id: b.$id,
|
||||
nummer: buchungsNummer(b.$id),
|
||||
status: b.status,
|
||||
quelle: b.quelle || 'online',
|
||||
startsAt: start.toISOString(),
|
||||
endsAt: ende.toISOString(),
|
||||
datum: tag,
|
||||
zeit,
|
||||
leistungId: b.leistungId || '',
|
||||
leistung,
|
||||
dauer: Math.round((ende.getTime() - start.getTime()) / 60000),
|
||||
titel: istSperre ? b.leistungName || 'Gesperrt' : b.gastName ? `${leistung}: ${b.gastName}` : leistung,
|
||||
person: b.ressourceName || '',
|
||||
ressourceId: b.ressourceId || '',
|
||||
gastName: b.gastName || '',
|
||||
gastEmail: b.gastEmail || '',
|
||||
gastTelefon: b.gastTelefon || '',
|
||||
gastNachricht: b.gastNachricht || '',
|
||||
erinnerung: b.erinnerung === true,
|
||||
zustellFehler: zustellFehlerIds.has(b.$id),
|
||||
angenommenAt: iso(b.angenommenAt),
|
||||
widerrufenAt: iso(b.widerrufenAt),
|
||||
abgesagtVon: b.abgesagtVon || '',
|
||||
absageGrund: b.absageGrund || '',
|
||||
}
|
||||
}
|
||||
|
||||
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
|
||||
|
||||
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = req.ctx
|
||||
const { von: vonTag, bis: bisTag, status } = req.query
|
||||
if (!tagGueltig(vonTag) || !tagGueltig(bisTag)) throw stop(400, MELDUNG.zeitraum, undefined, 'von')
|
||||
if (bisTag < vonTag) throw stop(400, MELDUNG.zeitraumUmgekehrt, undefined, 'bis')
|
||||
if (bisTag > plusTage(vonTag, MAX_TAGE)) throw stop(400, MELDUNG.zeitraumLang, undefined, 'bis')
|
||||
|
||||
let gewollt = null
|
||||
if (typeof status === 'string' && status !== '') {
|
||||
gewollt = status.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
if (gewollt.length === 0 || gewollt.some((s) => !STATUS.includes(s))) throw stop(400, MELDUNG.status, undefined, 'status')
|
||||
} else if (status !== undefined && typeof status !== 'string') {
|
||||
throw stop(400, MELDUNG.status, undefined, 'status')
|
||||
}
|
||||
|
||||
const anfang = zuUtc(vonTag, '00:00')
|
||||
const ende = zuUtc(bisTag, '24:00')
|
||||
const liste = (await imZeitraum(ctx.feature.$id, anfang, ende))
|
||||
.filter((b) => new Date(b.endsAt).getTime() > anfang.getTime()) // Pause nach dem Termin zählt nicht zum Zeitraum
|
||||
.filter((b) => gewollt === null || gewollt.includes(b.status))
|
||||
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
|
||||
const mitMail = await zustellFehler(liste.filter((b) => b.quelle !== 'sperre').map((b) => b.$id))
|
||||
res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) })
|
||||
}))
|
||||
|
||||
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = mitEinstellung(req)
|
||||
const { leistung, ressource = 'egal', tag, ohne } = req.query
|
||||
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
|
||||
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
|
||||
}
|
||||
if (typeof ressource !== 'string' || (ressource !== 'egal' && !(ctx.einstellung.ressourcen || []).some((r) => r.id === ressource))) {
|
||||
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
|
||||
}
|
||||
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
|
||||
if (ohne !== undefined && (typeof ohne !== 'string' || !ID_RE.test(ohne))) throw stop(400, MELDUNG.ungueltig, undefined, 'ohne')
|
||||
|
||||
const now = uhr()
|
||||
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
|
||||
// ohneRegeln: der Betrieb trägt auch kurzfristig und jenseits des Buchungszeitraums ein (nie in die Vergangenheit);
|
||||
// ohne = die Buchung, die verschoben wird, belegt ihre neue Zeit nicht selbst
|
||||
const zeiten = freieZeiten({
|
||||
einstellung: ctx.einstellung,
|
||||
leistungId: leistung,
|
||||
ressourceId: ressource,
|
||||
tag,
|
||||
belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}),
|
||||
now,
|
||||
ohneRegeln: true,
|
||||
})
|
||||
res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Buchungen schreiben
|
||||
|
||||
router.post('/buchungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const ctx = mitEinstellung(req)
|
||||
const { projectId: _projekt, art, ...roh } = req.body ?? {}
|
||||
if (art !== 'manuell' && art !== 'sperre') throw stop(400, MELDUNG.art, undefined, 'art')
|
||||
const now = uhr()
|
||||
const ergebnis = art === 'manuell' ? await manuell(ctx, roh, now) : await sperre(ctx, roh, now)
|
||||
await logIfKundenansicht(req, 'termin_manuell', art)
|
||||
res.status(201).json(ergebnis)
|
||||
}))
|
||||
|
||||
router.post('/buchungen/:id/aktion', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const ctx = mitEinstellung(req)
|
||||
const { projectId: _projekt, ...roh } = req.body ?? {}
|
||||
const id = String(req.params.id)
|
||||
const ergebnis = await aktion(ctx, id, roh, uhr())
|
||||
// ein zweiter Klick ändert nichts und steht nicht im Protokoll
|
||||
if (!ergebnis.schon) await logIfKundenansicht(req, 'termin_aktion', `${roh.aktion} ${buchungsNummer(id)}`)
|
||||
res.json(ergebnis)
|
||||
}))
|
||||
|
||||
router.delete('/buchungen/:id', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const ctx = mitEinstellung(req)
|
||||
const id = String(req.params.id)
|
||||
const ergebnis = await entsperre(ctx, id) // nur Sperren; alles andere ist 404
|
||||
await logIfKundenansicht(req, 'termin_aktion', `entsperren ${buchungsNummer(id)}`)
|
||||
res.json(ergebnis)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Export
|
||||
|
||||
/**
|
||||
* Eine Zelle der CSV-Datei: Anführungszeichen, Strichpunkt und Zeilenumbrüche werden gequotet; Zellen, die ein
|
||||
* Tabellenprogramm als Formel lesen würde (= + - @ am Anfang, auch nach Leerraum, Tab oder CR; Tab und CR am Anfang),
|
||||
* bekommen ein ' davor.
|
||||
*/
|
||||
function csvZelle(wert) {
|
||||
let zelle = String(wert ?? '')
|
||||
if (/^\s*[=+\-@]/.test(zelle) || /^[\t\r]/.test(zelle)) zelle = `'${zelle}`
|
||||
return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle
|
||||
}
|
||||
|
||||
router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = req.ctx
|
||||
// alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren
|
||||
const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z')))
|
||||
.filter((b) => b.quelle !== 'sperre')
|
||||
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
|
||||
const zeilen = [CSV_KOPF, ...liste.map((b) => {
|
||||
const start = new Date(b.startsAt)
|
||||
return [
|
||||
buchungsNummer(b.$id),
|
||||
STATUS_TEXT[b.status] ?? b.status,
|
||||
datumKurz(start),
|
||||
uhrzeit(start),
|
||||
b.leistungName,
|
||||
b.ressourceName,
|
||||
b.gastName,
|
||||
b.gastEmail,
|
||||
b.gastTelefon,
|
||||
b.gastNachricht,
|
||||
]
|
||||
})]
|
||||
const csv = `${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
|
||||
res.set({
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="termine.csv"',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
res.send(csv)
|
||||
}))
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user