Buchungskalender: Verdrahtung, Router-Gerüste und API-Vertrag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:59:38 +00:00
1 parent 1d2ca2791d
commit a68baf10d9
37 files changed
+888 -30

No files matched your search

@@ -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
}
+46
View File
@@ -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 () => ({}))
}
+123
View File
@@ -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)
}
+2 -1
View File
@@ -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')],
},
}