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
@@ -475,9 +475,12 @@ const MELDUNG_RESSOURCE = (r, n) => (r.typ === 'platz'
|
||||
* Prueft und normalisiert eine Einstellung (Eingabe vom Client oder aus einer Vorlage).
|
||||
* bisher = zuletzt gespeicherte Einstellung (Feed-Token, Storno-Fassung, Entfernen-Pruefung);
|
||||
* kuenftigeTermine = { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } } kuenftiger belegender Termine.
|
||||
* Pflichtangaben fuer Gaeste (fehlendePflichtangaben): Telefon immer, Anschrift bei Widerrufsbelehrung. entwurf: true
|
||||
* laesst nur diese beiden Pruefungen aus (Entwurf aus einer Vorlage, Kunde ohne Telefon/Anschrift im Konto); als
|
||||
* oeffentlich buchbar gilt eine solche Einstellung erst, wenn die Angaben da sind (kontextPerSiteKey).
|
||||
* => { einstellung } oder { fehler: { [pfad]: text } }.
|
||||
*/
|
||||
export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, now } = {}) {
|
||||
export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, entwurf = false, now } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereEinstellung: now fehlt')
|
||||
const fehler = {}
|
||||
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
|
||||
@@ -534,6 +537,10 @@ export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermi
|
||||
aufbewahrungTage: zahl(fehl, 'aufbewahrungTage', eingabe.aufbewahrungTage, { vorgabe: 90, min: 14, max: 365, meldung: 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.' }),
|
||||
}
|
||||
|
||||
if (!entwurf) {
|
||||
for (const pfad of fehlendePflichtangaben(einstellung)) fehl(pfad, MELDUNG_PFLICHT[pfad])
|
||||
}
|
||||
|
||||
if (Object.keys(fehler).length === 0 && JSON.stringify(einstellung).length > GRENZEN.json) {
|
||||
fehl('_', 'Die Einstellungen sind zu umfangreich. Bitte entferne Zeitfenster oder Einträge.')
|
||||
}
|
||||
@@ -550,6 +557,40 @@ function farbe(fehl, wert) {
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
|
||||
|
||||
const MELDUNG_PFLICHT = {
|
||||
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
|
||||
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
|
||||
}
|
||||
|
||||
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
|
||||
function sichtbareLeistungen(einstellung) {
|
||||
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
|
||||
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt die oeffentliche Sicht eine Widerrufsbelehrung (oeffentlicheEinstellung.widerruf)? Verbraucher-Gaeste und
|
||||
* mindestens eine sichtbare Leistung, die sofort gebucht wird.
|
||||
*/
|
||||
export function zeigtWiderruf(einstellung) {
|
||||
return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).some((l) => l.buchungsart === 'sofort')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pflichtangaben des Betriebs, die in einer Einstellung fehlen: 'betrieb.telefon' (immer noetig) und 'betrieb.adresse'
|
||||
* (noetig, sobald die Widerrufsbelehrung erscheint, zeigtWiderruf). Reihenfolge Telefon, Anschrift; [] = vollstaendig.
|
||||
* Ohne diese Angaben ist der Terminplaner fuer Gaeste nicht buchbar.
|
||||
*/
|
||||
export function fehlendePflichtangaben(einstellung) {
|
||||
const betrieb = einstellung?.betrieb || {}
|
||||
const fehlt = []
|
||||
if (typeof betrieb.telefon !== 'string' || betrieb.telefon.trim() === '') fehlt.push('betrieb.telefon')
|
||||
if (zeigtWiderruf(einstellung) && (typeof betrieb.adresse !== 'string' || betrieb.adresse.trim() === '')) fehlt.push('betrieb.adresse')
|
||||
return fehlt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Texte und Regeln
|
||||
|
||||
/** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */
|
||||
@@ -652,7 +693,7 @@ export function oeffentlicheEinstellung(einstellung, now) {
|
||||
zahlung: { modus: 'vorort', text: zahlungText(e) },
|
||||
preisHinweis: preisHinweis(e),
|
||||
storno: e.storno?.text ? { text: e.storno.text, version: e.storno.version } : null,
|
||||
widerruf: verbraucher && leistungen.some((l) => l.buchungsart === 'sofort'),
|
||||
widerruf: zeigtWiderruf(e),
|
||||
erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher },
|
||||
gastFelder: structuredClone(e.gastFelder),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js
|
||||
import { analyseHosts } from '../analyse.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { findActiveBySiteKey } from '../siteFeatures.js'
|
||||
import { fehlendePflichtangaben } from './einstellungen.js'
|
||||
import { termineAus } from './schalter.js'
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
|
||||
@@ -18,7 +19,8 @@ import { pruefeNow, pruefeText } from './util.js'
|
||||
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
|
||||
*
|
||||
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
|
||||
* Einstellung; Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
|
||||
* Einstellung, in der die Pflichtangaben fuer Gaeste stehen (fehlendePflichtangaben, Ruling R41: ein Entwurf aus einer
|
||||
* Vorlage ist noch nicht buchbar); Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
|
||||
* gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung
|
||||
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
|
||||
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
|
||||
@@ -98,7 +100,7 @@ export async function kontextPerSiteKey(siteKey, now = new Date()) {
|
||||
const stand = generation
|
||||
const feature = await findActiveBySiteKey(siteKey, FUNKTION)
|
||||
let ctx = feature ? await baueKontext(feature) : null
|
||||
if (ctx && !ctx.einstellung) ctx = null
|
||||
if (ctx && (!ctx.einstellung || fehlendePflichtangaben(ctx.einstellung).length > 0)) ctx = null
|
||||
if (stand === generation) merke(siteKey, ctx, nowMs)
|
||||
return ctx
|
||||
}
|
||||
@@ -135,22 +137,32 @@ const veraltet = () =>
|
||||
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
|
||||
* { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre
|
||||
* termine-cfg:<featureId>. Danach ist der Speicher von kontextPerSiteKey leer.
|
||||
* Statt der Einstellung darf eine Funktion `async (bisher) => einstellung` uebergeben werden (Ruling R40): sie laeuft
|
||||
* INNERHALB der Sperre nach der Versionspruefung, bekommt die gespeicherte Einstellung (Objekt) oder null und liefert die
|
||||
* zu speichernde; sie darf werfen (der Fehler geht unveraendert durch, es wird nichts geschrieben). So laufen Pruefungen,
|
||||
* die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie
|
||||
* das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand
|
||||
* aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch.
|
||||
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
|
||||
* @param {object} einstellung
|
||||
* @param {{ version: number, by?: string, now?: Date }} optionen
|
||||
* @param {object|((bisher: object|null) => object|Promise<object>)} einstellungOderFn
|
||||
* @param {{ version: number|null, by?: string, now?: Date }} optionen
|
||||
* @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version
|
||||
*/
|
||||
export async function speichereEinstellung(feature, einstellung, { version, by = '', now = new Date() } = {}) {
|
||||
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date() } = {}) {
|
||||
pruefeText(feature?.$id, 'feature.$id')
|
||||
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
|
||||
const alsFunktion = typeof einstellungOderFn === 'function'
|
||||
if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt')
|
||||
const nowMs = pruefeNow(now, 'speichereEinstellung')
|
||||
const json = JSON.stringify(einstellung)
|
||||
|
||||
return withLocks([`termine-cfg:${feature.$id}`], async () => {
|
||||
const col = config.collections.termineConfig
|
||||
const vorhanden = await holeOderNull(col, feature.$id)
|
||||
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
|
||||
if (version !== aktuell) throw veraltet()
|
||||
if (version !== null && version !== aktuell) throw veraltet()
|
||||
|
||||
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? (liesEinstellung(vorhanden)?.einstellung ?? null) : null) : einstellungOderFn
|
||||
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
|
||||
const json = JSON.stringify(einstellung)
|
||||
|
||||
const felder = {
|
||||
customerId: feature.customerId,
|
||||
|
||||
@@ -175,9 +175,11 @@ export function vorlagenListe() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gueltige Einstellung aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer,
|
||||
* Einstellung (Entwurf) aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer,
|
||||
* impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf.
|
||||
* Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler.
|
||||
* Telefon und Anschrift duerfen fehlen (normalisiereEinstellung mit entwurf); fehlendePflichtangaben(einstellung) nennt,
|
||||
* was vor dem oeffentlichen Betrieb noch gebraucht wird. Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein
|
||||
* Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler.
|
||||
*/
|
||||
export function ausVorlage(key, betrieb, now) {
|
||||
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`)
|
||||
@@ -208,7 +210,8 @@ export function ausVorlage(key, betrieb, now) {
|
||||
recht: { ...v.recht },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
|
||||
}
|
||||
const ergebnis = normalisiereEinstellung(eingabe, { now })
|
||||
// Entwurf: ein Kunde ohne Telefon/Anschrift im Konto kann trotzdem starten; die Angaben holt das Portal nach (fehlendePflichtangaben)
|
||||
const ergebnis = normalisiereEinstellung(eingabe, { now, entwurf: true })
|
||||
if (ergebnis.fehler) {
|
||||
const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.')
|
||||
err.fehler = ergebnis.fehler
|
||||
|
||||
Reference in new issue
Block a user