Buchungskalender: Verdrahtung, Router-Gerüste und API-Vertrag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d2ca2791d
commit
a68baf10d9
37 files changed
+888
-30
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { config } from '../config.js'
|
||||
import { termineFuerKunde } from './termine/schalter.js'
|
||||
import { shopFuerKunde } from './shop/schalter.js'
|
||||
import { unterkunftFuerKunde } from './unterkunft/schalter.js'
|
||||
import { zeitenFuerKunde } from './zeiten/schalter.js'
|
||||
|
||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
|
||||
@@ -99,6 +100,12 @@ async function shopBestellpruefung({ customerId, projectId }) {
|
||||
return anfrageOffenePunkte({ customerId, projectId })
|
||||
}
|
||||
|
||||
/** Vertragsunterlagen des Buchungskalenders abrufbar? (legalDocs.featureLegalReady, fail-closed, wirft nie.) */
|
||||
async function unterkunftUnterlagenBereit() {
|
||||
const { featureLegalReady } = await import('./legalDocs.js')
|
||||
return featureLegalReady('unterkunft')
|
||||
}
|
||||
|
||||
/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */
|
||||
async function shopUnterlagenBereit() {
|
||||
const { featureLegalReady } = await import('./legalDocs.js')
|
||||
@@ -127,6 +134,14 @@ const STANDARD_HAKEN = {
|
||||
unterlagenBereit: shopUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
},
|
||||
// Buchungskalender: Anfrage-Funktion und Freischaltung sind bis Task 11 Stubs (unterkunft/anfrageFunktion.js, freischaltung.js)
|
||||
unterkunft: {
|
||||
verfuegbar: (email) => unterkunftFuerKunde(email),
|
||||
bestellpruefung: (args) => import('./unterkunft/anfrageFunktion.js').then((m) => m.anfrageOffenePunkte(args)),
|
||||
unterlagenBereit: unterkunftUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
freischaltbar: (doc) => import('./unterkunft/freischaltung.js').then((m) => m.freischaltbar(doc)),
|
||||
},
|
||||
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung wie beim Terminplaner,
|
||||
// unterlagenBereit immer (die Oeffnungszeiten haben nur die Leistungsbeschreibung als Seite, keine PDFs); freischaltbar: erst mit
|
||||
// eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken
|
||||
|
||||
@@ -37,15 +37,21 @@ const SAETZE = {
|
||||
avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' },
|
||||
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
||||
},
|
||||
unterkunft: {
|
||||
lb: { slug: 'leistungsbeschreibung-unterkunft', pdf: 'Leistungsbeschreibung-Buchungskalender' },
|
||||
avv: { slug: 'avv-unterkunft', pdf: 'AVV-Buchungskalender' },
|
||||
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
||||
},
|
||||
}
|
||||
|
||||
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
|
||||
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' }
|
||||
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop', unterkunft: 'unterkunft' }
|
||||
|
||||
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
|
||||
const aktuelleFassung = (satz) => {
|
||||
if (satz === 'analyse') return config.analyse.avvVersion
|
||||
if (satz === 'shop') return config.shop.fassung
|
||||
if (satz === 'unterkunft') return config.unterkunft.fassung
|
||||
return config.termine.avvVersion
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ const PRODUCT_METADATA = {
|
||||
newsSetup: 'news-einrichtung',
|
||||
termineSetup: 'termine-einrichtung',
|
||||
shopSetup: 'shop-einrichtung',
|
||||
unterkunftSetup: 'unterkunft-einrichtung',
|
||||
zeitenSetup: 'zeiten-einrichtung',
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ function catalogFor(key) {
|
||||
if (key === 'newsSetup') return config.newsSetup
|
||||
if (key === 'termineSetup') return config.termineSetup
|
||||
if (key === 'shopSetup') return config.shopSetup
|
||||
if (key === 'unterkunftSetup') return config.unterkunftSetup
|
||||
if (key === 'zeitenSetup') return config.zeitenSetup
|
||||
return getProduct(key)
|
||||
}
|
||||
@@ -75,7 +77,7 @@ function catalogFor(key) {
|
||||
const priceIdCache = new Map()
|
||||
/**
|
||||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'unterkunftSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
|
||||
*/
|
||||
export async function ensurePrice(key) {
|
||||
|
||||
@@ -190,12 +190,13 @@ const fehlerText = (err) =>
|
||||
* Eingaben (TypeError) oder wenn das Speichern scheitert; dann ist nichts gespeichert und nichts gesendet.
|
||||
* `ics` und `icsMethod` gehoeren zusammen: die METHOD-Zeile der Kalenderdatei muss icsMethod entsprechen.
|
||||
* @param {{ featureId: string, bookingId: string, art: string, an: string, fromName: string, replyTo?: string|null,
|
||||
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} daten
|
||||
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH',
|
||||
* anhang?: string|null (Verweis auf einen Anhang, max. 500 Zeichen; wird nur gespeichert) }} daten
|
||||
* @param {Date} [now]
|
||||
* @returns {Promise<object>} das gespeicherte Dokument (Status wartet)
|
||||
*/
|
||||
export async function einreihen(
|
||||
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST' },
|
||||
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST', anhang = null },
|
||||
now = new Date(),
|
||||
) {
|
||||
for (const [wert, name] of [[featureId, 'featureId'], [bookingId, 'bookingId'], [art, 'art'], [an, 'an'], [fromName, 'fromName'], [template, 'template']]) {
|
||||
@@ -207,6 +208,8 @@ export async function einreihen(
|
||||
if (!new RegExp(`^METHOD:${icsMethod}\\r?$`, 'm').test(ics)) throw new TypeError(`ics: METHOD passt nicht zu icsMethod ${icsMethod}`)
|
||||
}
|
||||
|
||||
if (anhang !== null && anhang !== undefined && (typeof anhang !== 'string' || anhang.length > 500)) throw new TypeError('anhang muss ein Text bis 500 Zeichen sein')
|
||||
|
||||
const mail = renderMail(template, values, blocks)
|
||||
for (const problem of mail.problems) console.warn(`[termine-outbox] Vorlage ${template}: ${problem}`)
|
||||
|
||||
@@ -222,6 +225,7 @@ export async function einreihen(
|
||||
text: mail.text,
|
||||
ics: ics || '',
|
||||
icsMethod: ics ? icsMethod : '',
|
||||
anhang: anhang || '',
|
||||
status: 'wartet',
|
||||
versuche: 0,
|
||||
naechsterVersuch: iso(nowMs),
|
||||
|
||||
@@ -110,6 +110,8 @@ export const TERMINE_COLLECTIONS = {
|
||||
str('text', 50000),
|
||||
str('ics', 20000),
|
||||
str('icsMethod', 8),
|
||||
// optional: Verweis auf einen Anhang (Buchungskalender: Rechnungs-PDF, Task 20); wird nur gespeichert, das Senden bleibt unveraendert
|
||||
str('anhang', 500),
|
||||
str('status', 16),
|
||||
int('versuche'),
|
||||
dt('naechsterVersuch'),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Stub (Task 5): Anfrage-Funktion des Buchungskalenders. Task 11 ersetzt dieses Modul.
|
||||
export const ANFRAGE_OFFEN_TEXT = 'Anfrage noch nicht freigegeben'
|
||||
|
||||
/** Bestellpruefung-Haken: offene Punkte der Bestellung (Stub: immer offen). */
|
||||
export async function anfrageOffenePunkte() {
|
||||
return [ANFRAGE_OFFEN_TEXT]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stub (Task 5): Freischaltpruefung des Buchungskalenders. Task 11 ersetzt dieses Modul.
|
||||
|
||||
/** freischaltbar-Haken (Stub, fail-closed: nie freigeben). */
|
||||
export async function freischaltbar() {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { laufeFristenAb } from './ablauf.js'
|
||||
import { unterkunftModus } from './schalter.js'
|
||||
|
||||
/**
|
||||
* Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min,
|
||||
* taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten
|
||||
* Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 9/10 fuellen Viertelstunden- und Tagesjob.
|
||||
*/
|
||||
|
||||
const laufend = new Set()
|
||||
|
||||
async function einzeln(name, arbeit) {
|
||||
if (laufend.has(name)) {
|
||||
console.warn(`[unterkunft-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
|
||||
return { uebersprungen: true }
|
||||
}
|
||||
laufend.add(name)
|
||||
try {
|
||||
return await arbeit()
|
||||
} catch (err) {
|
||||
console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`)
|
||||
return { fehler: true }
|
||||
} finally {
|
||||
laufend.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Minutenjob: Fristen ablaufen lassen (Anfragen, Reservierungen). Bei UNTERKUNFT_ENABLED=off aus. */
|
||||
export async function minutenJob(now = new Date()) {
|
||||
if (unterkunftModus() === 'off') return { aus: true }
|
||||
return einzeln('minutenJob', () => laufeFristenAb(now))
|
||||
}
|
||||
|
||||
/** Viertelstundenjob (Task 9: iCal-Import). Bei UNTERKUNFT_ENABLED=off aus. */
|
||||
export async function viertelstundenJob(now = new Date()) {
|
||||
void now
|
||||
if (unterkunftModus() === 'off') return { aus: true }
|
||||
return einzeln('viertelstundenJob', async () => ({}))
|
||||
}
|
||||
|
||||
/** Tagesjob (Task 10: Aufbewahrung, Loeschen). Bei UNTERKUNFT_ENABLED=off aus. */
|
||||
export async function taeglicherJob(now = new Date()) {
|
||||
void now
|
||||
if (unterkunftModus() === 'off') return { aus: true }
|
||||
return einzeln('taeglicherJob', async () => ({}))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
|
||||
import { getFeatureDoc } from '../siteFeatures.js'
|
||||
import { unterkunftFuerKunde, unterkunftModus } from './schalter.js'
|
||||
import { ladeEinstellungen } from './speicher.js'
|
||||
|
||||
/**
|
||||
* Kontext eines Buchungskalenders (Funktionsschluessel 'unterkunft').
|
||||
*
|
||||
* ctx = { featureId, customerId, projectId, siteKey, state, besitzerEmail, einstellungen, project }
|
||||
* featureId ID des portalFeatures-Dokuments (zugleich ID der ukEinstellungen)
|
||||
* state Zustand der Funktion ('active' fuer die oeffentliche Schnittstelle)
|
||||
* besitzerEmail E-Mail des Kunden (leer, wenn der Kunde fehlt)
|
||||
* einstellungen ukEinstellungen (geparst) oder null, solange der Kunde noch nichts gespeichert hat
|
||||
* project websiteProjects-Dokument oder null
|
||||
* Ein ctx aus unterkunftPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck. Der Schalter
|
||||
* (off | pilot | on) gilt bei jedem Aufruf, nicht erst nach Ablauf des Speichers.
|
||||
*/
|
||||
|
||||
const FUNKTION = 'unterkunft'
|
||||
export const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
||||
const CACHE_MS = 30 * 1000
|
||||
const CACHE_MAX = 10000
|
||||
|
||||
const cache = new Map()
|
||||
/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
|
||||
let generation = 0
|
||||
|
||||
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
|
||||
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
|
||||
|
||||
async function holeOderNull(collectionId, id) {
|
||||
if (typeof id !== 'string' || id === '') return null
|
||||
try {
|
||||
return await getDocument(collectionId, id)
|
||||
} catch (err) {
|
||||
if (dokumentFehlt(err)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function bauKontext(doc) {
|
||||
const [kunde, project, einstellungen] = await Promise.all([
|
||||
holeOderNull(config.collections.customers, doc.customerId),
|
||||
holeOderNull(config.collections.websiteProjects, doc.projectId),
|
||||
ladeEinstellungen(doc.$id),
|
||||
])
|
||||
return {
|
||||
featureId: doc.$id,
|
||||
customerId: doc.customerId ?? '',
|
||||
projectId: doc.projectId ?? '',
|
||||
siteKey: doc.siteKey ?? '',
|
||||
state: doc.state ?? '',
|
||||
besitzerEmail: String(kunde?.email ?? ''),
|
||||
einstellungen,
|
||||
project,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext fuer die oeffentliche Schnittstelle: Funktion 'unterkunft' mit diesem siteKey im Zustand active. Modus off => null;
|
||||
* pilot => nur, wenn die Besitzer-E-Mail in der Pilotliste steht. Treffer und Fehlschlaege werden 30 Sekunden je siteKey gemerkt;
|
||||
* leereCache leert den Speicher.
|
||||
* @param {string} siteKey
|
||||
* @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests)
|
||||
* @returns {Promise<object|null>} null bei ungueltigem oder unbekanntem siteKey, anderem Zustand als active, Modus off und
|
||||
* Besitzer ausserhalb der Pilotliste
|
||||
*/
|
||||
export async function unterkunftPerSiteKey(siteKey, { now = new Date() } = {}) {
|
||||
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
|
||||
const modus = unterkunftModus()
|
||||
if (modus === 'off') return null
|
||||
const nowMs = now instanceof Date ? now.getTime() : Number(now)
|
||||
if (!Number.isFinite(nowMs)) throw new TypeError('unterkunftPerSiteKey: now ist kein gueltiger Zeitpunkt')
|
||||
|
||||
let ctx
|
||||
const gemerkt = cache.get(siteKey)
|
||||
if (gemerkt && gemerkt.bis > nowMs) {
|
||||
ctx = gemerkt.ctx
|
||||
} else {
|
||||
const stand = generation
|
||||
const [doc] = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('siteKey', siteKey),
|
||||
Query.equal('featureKey', FUNKTION),
|
||||
Query.equal('state', 'active'),
|
||||
Query.limit(1),
|
||||
])
|
||||
ctx = doc && doc.state === 'active' ? await bauKontext(doc) : null
|
||||
if (stand === generation) merke(siteKey, ctx, nowMs)
|
||||
}
|
||||
if (!ctx) return null
|
||||
if (modus === 'pilot' && !unterkunftFuerKunde(ctx.besitzerEmail)) return null
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext fuer das Portal: Funktion des Projekts in jedem Zustand ausser off (bestellt, Einrichtung, aktiv, abgeschaltet).
|
||||
* Immer frisch gelesen (kein Speicher). null, wenn das Projekt nicht dem Kunden gehoert, es keine Funktion gibt oder sie off ist.
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export async function ladeFuerProjekt(customerId, projectId) {
|
||||
const project = await holeOderNull(config.collections.websiteProjects, projectId)
|
||||
if (!project || !customerId || project.customerId !== customerId) return null
|
||||
const doc = await getFeatureDoc(customerId, projectId, FUNKTION)
|
||||
if (!doc || doc.state === 'off') return null
|
||||
const ctx = await bauKontext(doc)
|
||||
return { ...ctx, project }
|
||||
}
|
||||
|
||||
function merke(siteKey, ctx, nowMs) {
|
||||
if (cache.size >= CACHE_MAX) {
|
||||
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
|
||||
}
|
||||
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
|
||||
}
|
||||
|
||||
/** Leert den Speicher von unterkunftPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */
|
||||
export function leereCache(siteKey) {
|
||||
generation++
|
||||
if (siteKey === undefined) cache.clear()
|
||||
else cache.delete(siteKey)
|
||||
}
|
||||
@@ -147,7 +147,8 @@ export const UNTERKUNFT_COLLECTIONS = {
|
||||
str('anfrageBegruendung', 1000),
|
||||
dt('icalFehlerMailAt'),
|
||||
],
|
||||
indexes: [index('idx_feature', 'featureId')],
|
||||
// idx_account: account.updated des Connect-Webhooks findet die Einstellungen eines Kontos
|
||||
indexes: [index('idx_feature', 'featureId'), index('idx_account', 'stripeAccountId')],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user