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

+45
View File
@@ -0,0 +1,45 @@
# Buchungskalender: API-Vertrag
Verbindlich für Strang 2 (Widget und Buchungsseite, bauen gegen die öffentliche Schnittstelle) und Strang 3 (Portal, baut gegen die Kunden-API). Task 6 bis 8 erfüllen ihn. Fehlerform überall `{ fehler: code, felder?: {feld: text} }`.
## Öffentlich (`/api/public/unterkunft/v1`)
- `GET /:siteKey` → `{ aktiv:true, anbieter:{ name, ort, datenschutzUrl }, akzentfarbe, formToken, unterkuenfte:[{ id, name, maxPersonen, mindestNaechte, checkIn, checkOut }] }` oder `{ aktiv:false }`.
- `GET /:siteKey/belegung?unterkunft=<id>&von=YYYY-MM-DD&bis=YYYY-MM-DD` → `{ belegt:[{von,bis}], heute:'YYYY-MM-DD', bisMax:'YYYY-MM-DD' }` (Quellen nicht offengelegt).
- `POST /:siteKey/anfrage` Body `{ unterkunft, von, bis, personen, name, firma, email, telefon, nachricht, formToken, kontakt_url }` → `201 { nummer }`.
- `GET /:siteKey/buchung/:id?t=` → `{ status, nummer, unterkunft:{name,checkIn,checkOut}, von, bis, naechte, personen, posten, summen, steuerart, fristAt, stornobedingungen, anbieter:{firma,inhaber,strasse,plz,ort,email,telefon}, gast:{name,firma}, rechnungsadresse?, zahlarten:['vorort'|'online'], zahlart?, bezahlt:boolean, rechnungNummer? }`.
- `POST /:siteKey/buchung/:id?t=` Body `{ rechnungsadresse:{name,firma,strasse,plz,ort,land}, zahlart }` → `{ status:'gebucht', rechnungNummer, zahlungUrl? }` (bei `online` direkt Checkout-URL).
- `POST /:siteKey/buchung/:id/zahlung?t=` → `{ url }`.
- `GET /:siteKey/buchung/:id/rechnung.pdf?t=&art=rechnung|storno` → PDF.
## Kunde (`/api/unterkunft`, Session, `?projectId=`)
- `GET /status`
- `GET|PUT /einstellungen`
- `GET /unterkuenfte`
- `POST /unterkuenfte`
- `PUT|DELETE /unterkuenfte/:id`
- `GET|PUT /unterkuenfte/:id/preise`
- `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{von,bis,quelle,anfrageNummer?,sperreId?,notiz?}] }`
- `POST /unterkuenfte/:id/sperren`
- `DELETE /sperren/:id`
- `POST /unterkuenfte/:id/ical-importe`
- `DELETE /unterkuenfte/:id/ical-importe/:importId`
- `POST /unterkuenfte/:id/ical-export/erneuern`
- `GET /anfragen?filter=offen|gebucht|alle`
- `GET /anfragen/:id` (inkl. `vorschlag` = `berechnePreis`)
- `POST /anfragen/:id/freigeben` `{posten,fristTage,nachricht}`
- `POST /anfragen/:id/ablehnen` `{text}`
- `POST /anfragen/:id/bezahlt`
- `POST /anfragen/:id/stornieren` `{grund, erstattungCent}`
- `POST /preisvorschau` `{unterkunftId,von,bis,personen}`
- `GET /rechnungen`
- `GET /rechnungen/:id.pdf` (nur Einzel-PDF, kein ZIP — Spec N7)
- `POST /stripe/verbinden`, `POST /stripe/vorhandenes-konto`, `POST /stripe/aktualisieren`
- `POST /anfrage-funktion` `{art,einheiten,portale,nachricht}`
- `POST /anfrage-funktion/zurueckziehen`
## Team (`/api/team/unterkunft`)
- `GET /:customerId`
- `POST /:customerId/anfrage` `{projectId, entscheidung, begruendung}`
+4 -3
View File
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', zeiten: 'Öffnungszeiten' }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', zeiten: 'Öffnungszeiten' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1910,7 +1910,7 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', zeiten: 'zeiten' }
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
@@ -1920,6 +1920,7 @@ const viewRefreshers = {
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
buchungen: () => import('/buchungen.js').then((m) => m.initBuchungen(document.getElementById('buchungen-body'))),
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
}
@@ -2114,7 +2115,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
+4
View File
@@ -0,0 +1,4 @@
// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4.
export function initBuchungen(el) {
el.textContent = 'Buchungskalender wird geladen …'
}
+9
View File
@@ -207,6 +207,15 @@
<div id="webshop-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Buchungskalender: Unterkünfte, Preise, Anfragen, Rechnungen, Einstellungen (buchungen.js, initBuchungen); Unterseite von Funktionen -->
<section class="kb-view" id="view-buchungen">
<header class="kb-head">
<h1>Buchungskalender</h1>
<p>Anfragen und Buchungen für deine Unterkünfte – mit Belegungskalender und Rechnungen für deine Gäste.</p>
</header>
<div id="buchungen-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
<section class="kb-view" id="view-zeiten">
<header class="kb-head">
+2 -1
View File
@@ -17,7 +17,7 @@ import { config } from '../server/config.js'
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
import { PRODUCT_KEYS } from '../server/services/products.js'
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup']
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'unterkunftSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup']
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
export function parseArgs(argv) {
@@ -32,6 +32,7 @@ export function catalogEntry(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 config.products[key]
}
+130
View File
@@ -341,6 +341,86 @@ function shopConsent() {
}
}
/**
* Rechtsunterlage des Buchungskalenders: wie shopRechtsunterlage, aber mit Fassung und Adresse aus config.unterkunft.fassung
* (bei jedem Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.unterkunft); test/legalDocs.test.js gleicht beide ab.
*/
function unterkunftRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.unterkunft.fassung, url: `/rechtliches/${slug}-${config.unterkunft.fassung}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const unterkunftLb = unterkunftRechtsunterlage('leistungsbeschreibung-unterkunft')
const unterkunftAvv = unterkunftRechtsunterlage('avv-unterkunft')
const unterkunftWiderruf = unterkunftRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_DAUER = 'meist innerhalb von 1–2 Wochen'
/**
* Bestelltexte des Buchungskalenders: sechs Haken (alle Besteller), Aufbau wie shopConsent (Haken je Fassung einmal gebaut).
* Die Fassung (consent.version) ist config.unterkunft.fassung. Bei Textaenderung neues Fassungsdatum und neuen Hash im Pin von
* test/featureConsentTexte.test.js. Zitat, Wertersatz-Satz und Pflichtinformationen sind Entwurf bis zur Kanzlei.
*/
function unterkunftConsent() {
let versionGesetzt // undefined = config.unterkunft.fassung
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.unterkunft.fassung
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender in der Fassung ${v} zu.`,
},
{
id: 'verantwortung',
haken: true,
text: 'Ich vermiete in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten bin ich verantwortlich.',
},
{
id: 'rechnung',
haken: true,
text: 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG). Für Richtigkeit von Preisen und Steuerangaben bin ich verantwortlich.',
},
{
id: 'stripe',
haken: true,
text: 'Online-Zahlungen meiner Gäste laufen, sofern ich sie nutze, über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Gäste und erhebt keine Umsatzbeteiligung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Buchungskalenders beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Buchungskalenders für diese Website: Einbau des Kalender-Widgets, Verwaltung im Portal, Rechnungserstellung für Gäste), Gesamtpreis ${UNTERKUNFT_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${UNTERKUNFT_DAUER}, keine laufende Gebühr von WEBklar; der Buchungskalender läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.unterkunft.fassung ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${UNTERKUNFT_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
@@ -521,6 +601,56 @@ const features = {
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
},
unterkunft: {
key: 'unterkunft',
product: 'unterkunftSetup',
label: { name: 'Buchungskalender', kurz: 'Buchungskalender', keine: 'keinen Buchungskalender' },
priceLabel: UNTERKUNFT_PREIS,
setupEstimate: UNTERKUNFT_DAUER,
consent: unterkunftConsent(),
get leistungsbeschreibung() {
return unterkunftLb.get()
},
set leistungsbeschreibung(wert) {
unterkunftLb.set(wert)
},
get avv() {
return unterkunftAvv.get()
},
set avv(wert) {
unterkunftAvv.set(wert)
},
get widerruf() {
return unterkunftWiderruf.get()
},
set widerruf(wert) {
unterkunftWiderruf.set(wert)
},
returnHash: 'buchungen',
returnProjekt: true,
ticket: {
topic: (project) => `Buchungskalender einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { unterkunftTicketDetails } = await import('./services/unterkunft/ticketDetails.js')
return unterkunftTicketDetails(ctx)
},
},
mails: {
funktion: 'Buchungskalender',
formen: { dein: 'Dein Buchungskalender', nom: 'der Buchungskalender', akk: 'den Buchungskalender', gen: 'des Buchungskalenders', pron: 'ihn' },
knopf: 'Buchungen öffnen',
danach: 'Danach kannst Du im Portal Unterkünfte, Preise und Anfragen verwalten.',
bereit: 'Dein Buchungskalender ist eingebaut. Du kannst ab sofort Anfragen und Buchungen im Portal verwalten.',
preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage: 'Der Buchungskalender ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Buchungskalender ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Buchungskalender ist noch nicht verfügbar.',
},
// Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei)
zeiten: {
key: 'zeiten',
+32
View File
@@ -20,6 +20,8 @@ import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js'
import shopWebhookRoutes from './routes/shopWebhook.js'
import { shopSeiteRouter } from './routes/shopSeite.js'
import unterkunftRoutes from './routes/unterkunft.js'
import { unterkunftSeiteRouter } from './routes/unterkunftSeite.js'
import zeitenRoutes from './routes/zeiten.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
@@ -29,6 +31,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js'
import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js'
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
@@ -44,6 +47,7 @@ import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.js'
import publicShopRoutes from './routes/publicShop.js'
import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { legalRouter } from './services/legalDocs.js'
@@ -69,6 +73,8 @@ app.use(kundenansichtGuard)
app.use('/api/public/termine/v1', publicTermineRoutes)
// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser
app.use('/api/public/shop/v1', publicShopRoutes)
// Oeffentliche Schnittstelle des Buchungskalenders (Gaeste ohne Session): ebenfalls mit eigenem Parser VOR dem globalen
app.use('/api/public/unterkunft/v1', publicUnterkunftRoutes)
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
@@ -93,6 +99,7 @@ app.use('/api/termine', termineRoutes)
// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers
app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/unterkunft', unterkunftRoutes)
app.use('/api/zeiten', zeitenRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
@@ -129,6 +136,8 @@ app.use('/embed/', (_req, res, next) => {
app.use('/rechtliches', legalRouter())
// Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static)
app.use(shopSeiteRouter())
// Buchungskalender-Seiten fuer Gaeste: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id (vor express.static)
app.use(unterkunftSeiteRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
@@ -260,6 +269,29 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setInterval(shopTages, 24 * 60 * 60 * 1000).unref()
}, 200 * 1000).unref()
// Buchungskalender: jede Minute Fristen ablaufen lassen (80 s nach dem Start beginnend), alle 15 Minuten iCal-Importe (140 s),
// taeglich Aufbewahrung (210 s). Bei UNTERKUNFT_ENABLED=off kehrt jeder Lauf sofort zurueck. Die Jobs melden Einzelfehler selbst
// (ohne Gastdaten); hier faengt der Rahmen alles Unerwartete ab.
const unterkunftMinuten = () => unterkunftMinutenJob(new Date()).catch((err) => console.warn('[unterkunft] Minutenjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftMinuten()
setInterval(unterkunftMinuten, 60 * 1000).unref()
}, 80 * 1000).unref()
const unterkunftViertelstunden = () =>
unterkunftViertelstundenJob(new Date()).catch((err) => console.warn('[unterkunft] Viertelstundenjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftViertelstunden()
setInterval(unterkunftViertelstunden, 15 * 60 * 1000).unref()
}, 140 * 1000).unref()
const unterkunftTages = () =>
unterkunftTaeglicherJob(new Date())
.then((r) => r && !r.aus && !r.uebersprungen && console.log(`[unterkunft] Tagesjob: ${JSON.stringify(r)}`))
.catch((err) => console.warn('[unterkunft] Tagesjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftTages()
setInterval(unterkunftTages, 24 * 60 * 60 * 1000).unref()
}, 210 * 1000).unref()
// Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED,
// ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei
// Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und
+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()
+15
View File
@@ -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
+7 -1
View File
@@ -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
}
+3 -1
View File
@@ -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) {
+6 -2
View File
@@ -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),
+2
View File
@@ -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
}
+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')],
},
}
+4 -4
View File
@@ -103,15 +103,15 @@ afterEach(() => {
// ---------------------------------------------------------------------------------------- Register ---
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
NEWS.ticketFreischaltung = false
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'unterkunft', 'zeiten'])
NEWS.ticketFreischaltung = 'ja'
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'], 'nur true zaehlt')
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'unterkunft', 'zeiten'], 'nur true zaehlt')
NEWS.ticketFreischaltung = true
TERMINE.ticketFreischaltung = false
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'unterkunft', 'zeiten'])
} finally {
TERMINE.ticketFreischaltung = true
}
+3 -2
View File
@@ -122,11 +122,12 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet'
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
unterkunft: { name: 'Buchungskalender', keine: 'keinen Buchungskalender' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
})
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
})
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
+2 -2
View File
@@ -75,7 +75,7 @@ test('liste_zustaende', async () => {
for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true])
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'unterkunft', 'zeiten'])
assert.equal(body.features.shop.priceLabel, '299 €')
assert.equal(body.features.news.priceLabel, '30 €')
assert.equal(body.features.termine.priceLabel, '149 €')
@@ -118,7 +118,7 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be
const { legalPaths } = await import('../server/services/legalDocs.js')
const V = config.termine.avvVersion
const body = await get('/api/funktionen', { cookie })
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'unterkunft', 'zeiten'])
assert.equal(body.legal.news, null)
// die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen (avv und widerruf null): kein Eintrag wie beim Terminplaner
assert.equal(body.legal.zeiten, null)
+1 -1
View File
@@ -295,7 +295,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher'
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
+1 -1
View File
@@ -20,7 +20,7 @@ test('app.js: VIEW_HASHES, viewRefreshers, FEATURE_LABELS und APP_PARENT kennen
assert.match(app, /webshop: \(\) => import\('\/shop\.js'\)\.then\(\(m\) => m\.initShop\(document\.getElementById\('webshop-body'\)\)\)/)
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop folgt, zeiten (Öffnungszeiten live) steht am Ende
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', /)
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' \}/)
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' \}/)
// die Ansicht ist vom Hash aus erreichbar: kein zweiter Schlüssel mit demselben Hash
const hashes = app.match(/VIEW_HASHES = \{([^}]*)\}/)[1]
assert.equal([...hashes.matchAll(/'webshop'/g)].length, 1)
+4 -3
View File
@@ -185,19 +185,20 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
unterkunft: { name: 'Buchungskalender', keine: 'keinen Buchungskalender' },
})
})
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
const list = await sf.listCustomerFeatures('c1')
const p = list.find((x) => x.projectId === 'pt1')
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
@@ -263,7 +264,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s
const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
const reg = await import('../server/services/featureRegistry.js')
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
+1 -1
View File
@@ -32,7 +32,7 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () =>
})
test('KEYS enthaelt die Katalogschluessel der Einrichtungen (News, Terminplaner, Webshop, Oeffnungszeiten live)', () => {
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'])
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'unterkunftSetup', 'zeitenSetup'])
})
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
+12
View File
@@ -1278,3 +1278,15 @@ test('verwerfeWartende lässt verworfene Einträge in Ruhe (sie halten keine Gas
assert.equal(await verwerfeWartende('b_gast'), 0)
assert.equal((await lies(doc.$id)).status, 'verworfen')
})
test('einreihen: optionaler Anhang-Verweis wird gespeichert (Senden unverändert), ohne Angabe leer, zu lang = TypeError', async () => {
const mit = await einreihen(eintrag({ bookingId: 'b_anhang', anhang: 'uk-rechnung:r_123' }), JETZT)
assert.equal((await lies(mit.$id)).anhang, 'uk-rechnung:r_123')
const ohne = await einreihen(eintrag({ bookingId: 'b_ohne' }), JETZT)
assert.equal((await lies(ohne.$id)).anhang, '')
await assert.rejects(() => einreihen(eintrag({ anhang: 'x'.repeat(501) }), JETZT), TypeError)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 2, 'Versand nicht verändert')
const attribut = (await import('../server/services/termine/schema.js')).TERMINE_COLLECTIONS.termineOutbox.attributes.find((a) => a.key === 'anhang')
assert.deepEqual(attribut, { key: 'anhang', type: 'string', size: 500, required: false })
})
+1 -1
View File
@@ -88,7 +88,7 @@ test('Schema: Attribute je Collection wie in der Spezifikation', () => {
])
assert.deepEqual(keysOf('termineClaims'), ['featureId', 'ressourceId', 'bookingId', 'zelle', 'bis'])
assert.deepEqual(keysOf('termineOutbox'), [
'featureId', 'bookingId', 'art', 'an', 'fromName', 'replyTo', 'subject', 'html', 'text', 'ics', 'icsMethod',
'featureId', 'bookingId', 'art', 'an', 'fromName', 'replyTo', 'subject', 'html', 'text', 'ics', 'icsMethod', 'anhang',
'status', 'versuche', 'naechsterVersuch', 'gesendetAt', 'fehler', 'createdAt',
])
})
+286
View File
@@ -0,0 +1,286 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Buchungskalender Task 5: Verdrahtung in den gemeinsamen Dateien (Register, Haken, Rechtstexte, Stripe-Preis, Bestellweg,
// Routen, Kontext, Jobs, Webhook-Weiche, Portal-Quelltext, API-Vertrag).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
ukEinstellungen: schemaKeys('ukEinstellungen'),
},
datetimes: {},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, postWebhook } = await import('./support/testApp.js')
const reg = await import('../server/services/featureRegistry.js')
const legal = await import('../server/services/legalDocs.js')
const K = await import('../server/services/unterkunft/kontext.js')
const jobs = await import('../server/services/unterkunft/jobs.js')
const { default: publicRoutes } = await import('../server/routes/publicUnterkunft.js')
const { default: webhookRoutes } = await import('../server/routes/shopWebhook.js')
const W = await import('../server/routes/unterkunftWebhook.js')
const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes, '/api/shop/stripe-webhook': webhookRoutes })
after(async () => {
await app.close()
await aw.close()
await st.close()
})
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const SITE_KEY = 'abcdefghijklmnop'
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_unterkunft'
const T0 = new Date('2026-10-07T10:00:00.000Z')
const vorher = { ...config.unterkunft }
const urspruenglich = { mode: config.unterkunft.mode, pilotEmails: config.unterkunft.pilotEmails }
beforeEach(() => {
Object.assign(config.unterkunft, vorher)
K.leereCache()
W.setzeUkEreignisHandlerFuerTests(null)
})
afterEach(() => {
mock.restoreAll()
Object.assign(config.unterkunft, urspruenglich)
})
// -------------------------------------------------------------------------------------------------- Register
test('Register: Eintrag unterkunft mit Produkt unterkunftSetup, returnHash buchungen und den Texten der Spec', () => {
const e = reg.featureConfig('unterkunft')
assert.equal(e.key, 'unterkunft')
assert.equal(e.product, 'unterkunftSetup')
assert.equal(e.returnHash, 'buchungen')
assert.equal(e.returnProjekt, true)
assert.equal(e.requiresWartungsAbo, false)
assert.equal(e.ticketFreischaltung, true)
assert.equal(e.label.name, 'Buchungskalender')
assert.equal(e.priceLabel, '199 €')
assert.deepEqual(e.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'rechnung', 'stripe', 'beginn', 'information'])
assert.match(e.consent.items.find((i) => i.id === 'rechnung').text, /§ 14 Abs\. 2 Satz 4 UStG/)
assert.equal(e.consent.version, config.unterkunft.fassung)
assert.equal(e.leistungsbeschreibung.url, `/rechtliches/leistungsbeschreibung-unterkunft-${config.unterkunft.fassung}.html`)
assert.equal(e.avv.url, `/rechtliches/avv-unterkunft-${config.unterkunft.fassung}.html`)
assert.equal(e.widerruf.url, `/rechtliches/widerrufsbelehrung-funktionen-${config.unterkunft.fassung}.html`)
assert.equal(e.ticket.topic({ projectName: 'Donau Home' }), 'Buchungskalender einbauen – Donau Home')
assert.ok(reg.featureKeys().includes('unterkunft'))
assert.equal(config.unterkunftSetup.priceNickname, 'Buchungskalender-Einrichtung 199 €')
})
test('Haken: featureHaken(unterkunft) hat alle fünf, Stubs und Schalter wirken', async () => {
const h = reg.featureHaken('unterkunft')
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']) assert.equal(typeof h[name], 'function', name)
config.unterkunft.mode = 'off'
assert.equal(h.verfuegbar('a@b.de'), false)
config.unterkunft.mode = 'on'
assert.equal(h.verfuegbar('a@b.de'), true)
assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), ['Anfrage noch nicht freigegeben'])
assert.equal(await h.freischaltbar({ $id: FEATURE }), false)
})
test('Rechtstexte: Satz unterkunft mit Slugs und PDF-Namen, Funktion verweist darauf', () => {
const p = legal.featureLegalPaths('unterkunft')
assert.deepEqual(p, {
lb: `/rechtliches/leistungsbeschreibung-unterkunft-${config.unterkunft.fassung}.html`,
avv: `/rechtliches/avv-unterkunft-${config.unterkunft.fassung}.html`,
widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${config.unterkunft.fassung}.html`,
})
assert.deepEqual(Object.keys(legal.legalPdfFiles('unterkunft', config.unterkunft.fassung)).map((k) => legal.legalPdfFiles('unterkunft', '2026-10-07')[k].filename), [
'Leistungsbeschreibung-Buchungskalender-2026-10-07.pdf',
'AVV-Buchungskalender-2026-10-07.pdf',
'Widerrufsbelehrung-Muster-Widerrufsformular-2026-10-07.pdf',
])
})
test('Stripe: Preiskatalog, Skript-Schlüssel und Bestellweg kennen unterkunftSetup', async () => {
const sb = lies('server/services/stripeBilling.js')
assert.match(sb, /unterkunftSetup: 'unterkunft-einrichtung'/)
assert.match(sb, /key === 'unterkunftSetup'\) return config\.unterkunftSetup/)
const { KEYS, catalogEntry } = await import('../scripts/stripe-products.mjs')
assert.ok(KEYS.includes('unterkunftSetup'))
assert.equal(catalogEntry('unterkunftSetup').amountCents, 19900)
const abo = lies('server/routes/abo.js')
assert.match(abo, /ORDERS = \[[^\]]*'unterkunft'/)
assert.match(abo, /FEATURE_OF_ORDER = \{[^}]*unterkunft: 'unterkunft'/)
assert.match(abo, /'Buchungskalender \(Einrichtung\)'/)
assert.match(lies('server/routes/funktionen.js'), /unterkunft: \{ verfuegbar: unterkunftFuerKunde\(req\.session\.email\) \}/)
})
// -------------------------------------------------------------------------------------------------- Portal-Quelltext
test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => {
const app = lies('public/app.js')
assert.match(app, /VIEW_HASHES = \{[^}]*buchungen: 'buchungen'/)
assert.match(app, /buchungen: \(\) => import\('\/buchungen\.js'\)\.then\(\(m\) => m\.initBuchungen\(document\.getElementById\('buchungen-body'\)\)\)/)
assert.match(app, /APP_PARENT = \{[^}]*buchungen: 'funktionen'/)
assert.match(app, /FEATURE_LABELS = \{[^}]*unterkunft: 'Buchungskalender'/)
const html = lies('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\s\S]*?<\/section>/)
assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1)
assert.match(lies('public/buchungen.js'), /export function initBuchungen\(el\)/)
})
test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {
const index = lies('server/index.js')
assert.match(index, /app\.use\('\/api\/public\/unterkunft\/v1', publicUnterkunftRoutes\)/)
assert.ok(index.indexOf("'/api/public/unterkunft/v1'") < index.indexOf("express.json({ limit: '2mb'"), 'vor dem globalen Parser')
assert.match(index, /app\.use\('\/api\/unterkunft', unterkunftRoutes\)/)
assert.ok(index.indexOf('app.use(unterkunftSeiteRouter())') < index.indexOf('app.use(express.static(publicDir))'), 'vor express.static')
assert.match(index, /}, 80 \* 1000\)\.unref\(\)[\s\S]*?60 \* 1000/)
assert.match(index, /unterkunftViertelstunden, 15 \* 60 \* 1000/)
assert.match(index, /unterkunftTages, 24 \* 60 \* 60 \* 1000/)
assert.match(lies('server/routes/team/index.js'), /router\.use\('\/unterkunft', unterkunftTeamRoutes\)/)
})
test('API-Vertrag: docs/funktionen/unterkunft-api.md nennt alle Pfade', () => {
const doc = lies('docs/funktionen/unterkunft-api.md')
for (const teil of ['GET /:siteKey/belegung', 'POST /:siteKey/anfrage', '/buchung/:id/zahlung', 'rechnung.pdf', '/unterkuenfte/:id/kalender', '/anfragen/:id/stornieren', '/preisvorschau', '/rechnungen/:id.pdf', '/stripe/vorhandenes-konto', '/anfrage-funktion/zurueckziehen', '/api/team/unterkunft', 'N7']) {
assert.ok(doc.includes(teil), teil)
}
})
// -------------------------------------------------------------------------------------------------- Öffentliche Schnittstelle
test('Öffentlich: GET /api/public/unterkunft/v1/<siteKey> liefert {aktiv:false} (Gerüst)', async () => {
const r = await rawGet('/api/public/unterkunft/v1/aaaaaaaaaaaaaaaa')
assert.equal(r.status, 200)
assert.deepEqual(r.body, { aktiv: false })
})
// -------------------------------------------------------------------------------------------------- Kontext
function seedFeature(over = {}) {
aw.seed('portalFeatures', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'unterkunft', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Donau Home' })
aw.seed('customers', { $id: KUNDE, name: 'Mimi', email: 'mimi@example.com' })
}
async function leere() {
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
for (const c of ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
}
test('Kontext: unterkunftPerSiteKey beachtet Modus, Zustand, Pilotliste, lädt Einstellungen und merkt 30 Sekunden', async () => {
await leere()
seedFeature()
config.unterkunft.mode = 'off'
assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null)
config.unterkunft.mode = 'on'
assert.equal(await K.unterkunftPerSiteKey('kurz', { now: T0 }), null)
const ctx = await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(ctx.featureId, FEATURE)
assert.equal(ctx.customerId, KUNDE)
assert.equal(ctx.projectId, PROJEKT)
assert.equal(ctx.siteKey, SITE_KEY)
assert.equal(ctx.state, 'active')
assert.equal(ctx.besitzerEmail, 'mimi@example.com')
assert.equal(ctx.einstellungen, null, 'vor dem Setup noch keine Einstellungen')
assert.equal(ctx.project.projectName, 'Donau Home')
assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: new Date(T0.getTime() + 20_000) }), ctx, 'gemerkt')
assert.notEqual(await K.unterkunftPerSiteKey(SITE_KEY, { now: new Date(T0.getTime() + 31_000) }), ctx, 'nach 30 s neu')
// Pilot: nur Besitzer aus der Liste; der Modus gilt auch bei gemerktem Kontext
config.unterkunft.mode = 'pilot'
config.unterkunft.pilotEmails = ['andere@example.com']
assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null)
config.unterkunft.pilotEmails = ['mimi@example.com']
assert.ok(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }))
// leereCache + Zustand disabled => null
config.unterkunft.mode = 'on'
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'disabled' })
K.leereCache(SITE_KEY)
assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null)
})
test('Kontext: Einstellungen kommen geparst ins ctx; ladeFuerProjekt liest jeden Zustand außer off und prüft den Kunden', async () => {
await leere()
seedFeature({ state: 'setup' })
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', stripeStatus: JSON.stringify({ charges_enabled: true }) })
config.unterkunft.mode = 'on'
assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null, 'setup ist für Gäste nicht erreichbar')
const ctx = await K.ladeFuerProjekt(KUNDE, PROJEKT)
assert.equal(ctx.state, 'setup')
assert.equal(ctx.einstellungen.firma, 'Donau Home')
assert.deepEqual(ctx.einstellungen.stripeStatus, { charges_enabled: true })
assert.equal(ctx.project.$id, PROJEKT)
assert.equal(await K.ladeFuerProjekt('c_fremd', PROJEKT), null)
assert.equal(await K.ladeFuerProjekt(KUNDE, 'p_gibtsnicht'), null)
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' })
assert.equal(await K.ladeFuerProjekt(KUNDE, PROJEKT), null)
})
// -------------------------------------------------------------------------------------------------- Jobs
test('Jobs: im Modus off {aus:true}; Minutenjob ruft die Fristen ab, die anderen laufen leer durch', async () => {
config.unterkunft.mode = 'off'
assert.deepEqual(await jobs.minutenJob(T0), { aus: true })
assert.deepEqual(await jobs.viertelstundenJob(T0), { aus: true })
assert.deepEqual(await jobs.taeglicherJob(T0), { aus: true })
await leere()
config.unterkunft.mode = 'on'
const r = await jobs.minutenJob(T0)
assert.notEqual(r?.aus, true)
assert.notEqual(r?.fehler, true, JSON.stringify(r))
assert.deepEqual(await jobs.viertelstundenJob(T0), {})
assert.deepEqual(await jobs.taeglicherJob(T0), {})
})
// -------------------------------------------------------------------------------------------------- Webhook
const SECRET = 'whsec_connect_test'
const PFAD = '/api/shop/stripe-webhook'
const ACCOUNT = 'acct_vermieter1'
let nr = 0
const ereignis = (type, objekt, over = {}) => ({ id: `evt_uk${(nr += 1)}`, object: 'event', type, livemode: false, account: ACCOUNT, data: { object: objekt }, ...over })
test('Webhook: Checkout mit wkUk und Erstattung mit webklar=unterkunft gehen an verarbeiteUkEreignis, alles andere nicht', async () => {
config.shop.connectWebhookSecret = SECRET
const gesehen = []
W.setzeUkEreignisHandlerFuerTests(async (event) => void gesehen.push(event.type))
let r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_1', payment_status: 'paid', metadata: { wkUk: '1', anfrageId: 'a1' } }), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
r = await postWebhook(ereignis('charge.refunded', { id: 'ch_1', metadata: { webklar: 'unterkunft' } }), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
assert.deepEqual(gesehen, ['checkout.session.completed', 'charge.refunded'])
// Shop-Sitzung und Fremdes: nicht an Uk
r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_2', payment_status: 'paid', metadata: { wkShop: '1', orderId: 'o_gibtsnicht' } }), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
assert.deepEqual(gesehen, ['checkout.session.completed', 'charge.refunded'], 'Shop-Ereignis geht nicht an Uk')
r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_3', payment_status: 'paid', metadata: {} }), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
assert.equal(gesehen.length, 2)
})
test('Webhook: account.updated speichert den Status auch in ukEinstellungen mit demselben Konto (nur bei Modus ≠ off)', async () => {
await leere()
config.shop.connectWebhookSecret = SECRET
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: false }) })
aw.seed('ukEinstellungen', { $id: 'f_anders', featureId: 'f_anders', stripeAccountId: 'acct_anderer', stripeStatus: JSON.stringify({ charges_enabled: false }) })
const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } }
const status = (id) => JSON.parse(aw.docs('ukEinstellungen').get(id).stripeStatus)
config.unterkunft.mode = 'off'
assert.equal((await postWebhook(ereignis('account.updated', konto), { path: PFAD, secret: SECRET })).status, 200)
assert.equal(status(FEATURE).charges_enabled, false, 'off: keine Wirkung')
config.unterkunft.mode = 'on'
assert.equal((await postWebhook(ereignis('account.updated', konto), { path: PFAD, secret: SECRET })).status, 200)
assert.equal(status(FEATURE).charges_enabled, true)
assert.equal(status(FEATURE).payouts_enabled, true)
assert.ok(status(FEATURE).geprueftAt)
assert.equal(status('f_anders').charges_enabled, false, 'anderes Konto bleibt unberührt')
assert.equal(await W.kontoAktualisiertUk('acct_unbekannt', konto, T0), 0)
assert.equal(await W.kontoAktualisiertUk('kein-konto', konto, T0), 0)
})
+3 -3
View File
@@ -38,10 +38,10 @@ test('zeiten steht im Register mit 30 € und Abo-Inklusive', () => {
assert.equal(f.orderedConflictMessage, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.')
assert.equal(f.unavailableMessage, 'Öffnungszeiten live ist noch nicht verfügbar.')
// Funktionsschlüssel und Katalog stehen in den abgeleiteten Listen
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_LABELS.zeiten, { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' })
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
})
test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', () => {