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

+4 -3
View File
@@ -727,15 +727,15 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten']
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -810,6 +810,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
if (order === 'termine') return 'Terminplaner (Einrichtung)'
if (order === 'shop') return 'Webshop (Einrichtung)'
if (order === 'unterkunft') return 'Buchungskalender (Einrichtung)'
if (order === 'zeiten') return 'Öffnungszeiten live'
const abo = productNameOf(sub)
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
+3
View File
@@ -20,6 +20,7 @@ import {
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
import { starteBehalten } from '../services/zeiten/behalten.js'
@@ -238,6 +239,8 @@ router.get('/', requireSession, async (req, res) => {
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
// kommen aus projects[].features.shop und GET /api/shop/status
shop: { verfuegbar: shopFuerKunde(req.session.email) },
// Buchungskalender: Schalter UNTERKUNFT_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
unterkunft: { verfuegbar: unterkunftFuerKunde(req.session.email) },
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
zeiten: await zeitenAngebot(req.session.email, customerId, abos),
+17
View File
@@ -0,0 +1,17 @@
import express, { Router } from 'express'
/**
* Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md).
* Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js).
* Geruest (Task 5): Task 6 fuellt Konfiguration, Belegung und Anfrage, Task 7 die Buchungsseite.
*/
const router = Router()
router.use(express.json({ limit: '16kb' }))
// Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage
// Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf
/** Solange nichts gefuellt ist: jede Seite ist (noch) nicht aktiv, ohne zu verraten, ob der Schluessel existiert. */
router.get('/:siteKey', (_req, res) => res.json({ aktiv: false }))
export default router
+9
View File
@@ -6,6 +6,7 @@ import { connectEreignis, statusAusKonto, statusMitHinweis } from '../services/s
import { meldeEingang, schliesseAb, verwirf } from '../services/shop/bestellungen.js'
import { bucheStripeErstattung } from '../services/shop/erstattungen.js'
import { ladeShop, leereCache } from '../services/shop/kontext.js'
import { kontoAktualisiertUk, verarbeiteUkEreignis } from './unterkunftWebhook.js'
/**
* Stripe-Connect-Webhook des Webshops: POST /api/shop/stripe-webhook (Plan 2026-10-05-webshop.md, Task D3).
@@ -41,10 +42,18 @@ async function verarbeite(event, now) {
if (event.type === 'account.updated') {
await kontoAktualisiert(accountId || objekt.id, objekt, now)
// dasselbe Konto kann auch im Buchungskalender haengen
await kontoAktualisiertUk(accountId || objekt.id, objekt, now)
return
}
if (!accountId) return
// Buchungskalender: Checkout-Sitzungen mit wkUk, Erstattungen nach den Metadaten der Zahlung
if (objekt.metadata?.wkUk === '1' || (event.type === 'charge.refunded' && objekt.metadata?.webklar === 'unterkunft')) {
await verarbeiteUkEreignis(event, now)
return
}
if (event.type === 'charge.refunded') {
await bucheStripeErstattung({ accountId, charge: objekt, now })
return
+3
View File
@@ -9,6 +9,7 @@ import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
import shopTeamRoutes from './shop.js'
import unterkunftTeamRoutes from './unterkunft.js'
import termineTeamRoutes from './termine.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
@@ -28,5 +29,7 @@ router.use('/funktionen', funktionenAdminRouter)
router.use('/termine', termineTeamRoutes)
// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/shop', shopTeamRoutes)
// Buchungskalender: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/unterkunft', unterkunftTeamRoutes)
export default router
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express'
/**
* Buchungskalender im Ticketsystem: GET /api/team/unterkunft/:customerId (Stand je Projekt) und
* POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion).
* Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt. Geruest (Task 5): Task 11 fuellt es.
*/
const router = Router()
export default router
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express'
/**
* Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId=; Vertrag: docs/funktionen/unterkunft-api.md).
* Geruest (Task 5): Task 8 fuellt Status, Einstellungen, Unterkuenfte, Preise, Kalender, Sperren, iCal, Anfragen, Rechnungen
* und Stripe; Task 11 die Anfrage-Funktion (/anfrage-funktion).
*/
const router = Router()
export default router
+12
View File
@@ -0,0 +1,12 @@
import { Router } from 'express'
/**
* Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id
* (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten.
*/
export function unterkunftSeiteRouter() {
const router = Router()
return router
}
export default unterkunftSeiteRouter
+59
View File
@@ -0,0 +1,59 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { statusAusKonto } from '../services/shop/connect.js'
import { unterkunftModus } from '../services/unterkunft/schalter.js'
/**
* Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto)
* leitet Ereignisse mit metadata.wkUk === '1' (Checkout-Sitzungen) oder metadata.webklar === 'unterkunft' (Erstattungen) hierher
* und ruft bei account.updated zusaetzlich kontoAktualisiertUk. Alles idempotent; Fehler => 500 (Stripe wiederholt).
*/
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
/**
* Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten. Geruest (Task 5): Task 8/9 fuellen es (Zahlung eingegangen,
* Sitzung abgelaufen, Erstattung gebucht). Bis dahin ohne Wirkung.
* @param {object} event Stripe-Ereignis (signiert und geprueft)
* @param {Date} [now]
*/
export async function verarbeiteUkEreignis(event, now = new Date()) {
return ereignisHandler(event, now)
}
// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen); Task 8/9 ersetzen den Standard durch die echte Verarbeitung
let ereignisHandler = async () => {}
export function setzeUkEreignisHandlerFuerTests(fn) {
ereignisHandler = fn ?? (async () => {})
}
/**
* account.updated: Bereitschaftsstatus (stripeStatus) aller Einstellungen mit diesem Konto neu speichern. Bei UNTERKUNFT_ENABLED=off
* und solange die Collection fehlt (Setup-Skript noch nicht gelaufen) ohne Wirkung.
* @returns {Promise<number>} Zahl aktualisierter Einstellungen
*/
export async function kontoAktualisiertUk(accountId, konto, now = new Date()) {
if (unterkunftModus() === 'off' || !ACCOUNT_RE.test(accountId || '')) return 0
let dokumente
try {
dokumente = await listDocuments(config.collections.ukEinstellungen, [Query.equal('stripeAccountId', accountId), Query.limit(25)])
} catch (err) {
if (err?.status === 404) return 0
throw err
}
let n = 0
for (const doc of dokumente) {
await withLocks([`uk-stripe:${doc.$id}`], async () => {
const frisch = await getDocument(config.collections.ukEinstellungen, doc.$id)
if (frisch.stripeAccountId !== accountId) return
await updateDocument(config.collections.ukEinstellungen, doc.$id, { stripeStatus: JSON.stringify(statusAusKonto(konto, now)) })
n++
})
}
return n
}
// Kein Endpunkt: die Datei haengt nur Funktionen an. Der leere Router erlaubt ein spaeteres Einhaengen ohne Aenderung am Import.
export default Router()