diff --git a/docs/funktionen/unterkunft-api.md b/docs/funktionen/unterkunft-api.md new file mode 100644 index 0000000..c82c8ba --- /dev/null +++ b/docs/funktionen/unterkunft-api.md @@ -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=&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}` diff --git a/public/app.js b/public/app.js index b9251b8..ff70244 100644 --- a/public/app.js +++ b/public/app.js @@ -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])' diff --git a/public/buchungen.js b/public/buchungen.js new file mode 100644 index 0000000..fb992af --- /dev/null +++ b/public/buchungen.js @@ -0,0 +1,4 @@ +// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4. +export function initBuchungen(el) { + el.textContent = 'Buchungskalender wird geladen …' +} diff --git a/public/dashboard.html b/public/dashboard.html index 0401f1d..883f671 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -207,6 +207,15 @@

Lade…

+ +
+
+

Buchungskalender

+

Anfragen und Buchungen für deine Unterkünfte – mit Belegungskalender und Rechnungen für deine Gäste.

+
+

Lade…

+
+
diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index 53377ca..04c6ddf 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -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] } diff --git a/server/config.js b/server/config.js index 36c2fe2..e5b7c62 100644 --- a/server/config.js +++ b/server/config.js @@ -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', diff --git a/server/index.js b/server/index.js index 49a6335..9736ef6 100644 --- a/server/index.js +++ b/server/index.js @@ -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 diff --git a/server/routes/abo.js b/server/routes/abo.js index 894c5c3..739d185 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -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})` : ''}` diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index b122bc4..35778e0 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -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), diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js new file mode 100644 index 0000000..0fff1a4 --- /dev/null +++ b/server/routes/publicUnterkunft.js @@ -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 diff --git a/server/routes/shopWebhook.js b/server/routes/shopWebhook.js index 0b929b5..d0a42d5 100644 --- a/server/routes/shopWebhook.js +++ b/server/routes/shopWebhook.js @@ -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 diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 5c803fc..c0b4f65 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -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 diff --git a/server/routes/team/unterkunft.js b/server/routes/team/unterkunft.js new file mode 100644 index 0000000..37dadfe --- /dev/null +++ b/server/routes/team/unterkunft.js @@ -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 diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js new file mode 100644 index 0000000..020e4a8 --- /dev/null +++ b/server/routes/unterkunft.js @@ -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 diff --git a/server/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js new file mode 100644 index 0000000..3d78e25 --- /dev/null +++ b/server/routes/unterkunftSeite.js @@ -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 diff --git a/server/routes/unterkunftWebhook.js b/server/routes/unterkunftWebhook.js new file mode 100644 index 0000000..8c9f892 --- /dev/null +++ b/server/routes/unterkunftWebhook.js @@ -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} 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() diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 9967116..2a028e8 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -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 diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js index f7b1ede..ac7030b 100644 --- a/server/services/legalDocs.js +++ b/server/services/legalDocs.js @@ -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 } diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 1ff2993..076dc0a 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -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) { diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index 15e0b64..e305cfa 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -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} 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), diff --git a/server/services/termine/schema.js b/server/services/termine/schema.js index 9a7b42e..2808550 100644 --- a/server/services/termine/schema.js +++ b/server/services/termine/schema.js @@ -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'), diff --git a/server/services/unterkunft/anfrageFunktion.js b/server/services/unterkunft/anfrageFunktion.js new file mode 100644 index 0000000..93c4931 --- /dev/null +++ b/server/services/unterkunft/anfrageFunktion.js @@ -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] +} diff --git a/server/services/unterkunft/freischaltung.js b/server/services/unterkunft/freischaltung.js new file mode 100644 index 0000000..cf66a75 --- /dev/null +++ b/server/services/unterkunft/freischaltung.js @@ -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 +} diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js new file mode 100644 index 0000000..71d1548 --- /dev/null +++ b/server/services/unterkunft/jobs.js @@ -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 () => ({})) +} diff --git a/server/services/unterkunft/kontext.js b/server/services/unterkunft/kontext.js new file mode 100644 index 0000000..830e783 --- /dev/null +++ b/server/services/unterkunft/kontext.js @@ -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} 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} + */ +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) +} diff --git a/server/services/unterkunft/schema.js b/server/services/unterkunft/schema.js index 55cbf10..7fabd85 100644 --- a/server/services/unterkunft/schema.js +++ b/server/services/unterkunft/schema.js @@ -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')], }, } diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index 3ef5df0..2efabcc 100644 --- a/test/featureFreischaltung.test.js +++ b/test/featureFreischaltung.test.js @@ -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 } diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index 57496b3..aa8d59a 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -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 --- diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index 7c070c1..91a15a2 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -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) diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 20363bf..04c3f54 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -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 diff --git a/test/shopWiring.test.js b/test/shopWiring.test.js index 8188b75..858823e 100644 --- a/test/shopWiring.test.js +++ b/test/shopWiring.test.js @@ -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) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index 703d741..74efe9a 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -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']) diff --git a/test/stripeProductsScript.test.js b/test/stripeProductsScript.test.js index d9474bf..f521921 100644 --- a/test/stripeProductsScript.test.js +++ b/test/stripeProductsScript.test.js @@ -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', () => { diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index eb1913c..9438147 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -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 }) +}) diff --git a/test/termineSetup.test.js b/test/termineSetup.test.js index 219ff97..8cf149f 100644 --- a/test/termineSetup.test.js +++ b/test/termineSetup.test.js @@ -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', ]) }) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js new file mode 100644 index 0000000..c84f44b --- /dev/null +++ b/test/unterkunftWiring.test.js @@ -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, /
[\s\S]*?
[\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/ 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) +}) diff --git a/test/zeitenRegister.test.js b/test/zeitenRegister.test.js index 845e45a..9bba06a 100644 --- a/test/zeitenRegister.test.js +++ b/test/zeitenRegister.test.js @@ -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', () => {