Buchungskalender: Konfiguration, Schalter, Schema und Setup-Skript

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:45:57 +00:00
1 parent 18369a348c
commit 5717a5cdd0
7 files changed
+728

No files matched your search

+60
View File
@@ -31,6 +31,7 @@ function nonNegativeNumber(raw, fallback) {
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
@@ -137,6 +138,16 @@ const shopSetup = {
priceNickname: 'Webshop-Einrichtung 299 €',
}
// Buchungskalender (Funktionsschluessel 'unterkunft'): einmalige Einrichtung (Einmalpreis, kein Abo)
const unterkunftSetup = {
key: 'unterkunftSetup',
name: 'Buchungskalender (Einrichtung)',
amountCents: 19900,
lookupKey: 'webklar_unterkunft_einrichtung_19900',
oneTime: true,
priceNickname: 'Buchungskalender-Einrichtung 199 €',
}
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
const zeitenSetup = {
key: 'zeitenSetup',
@@ -631,6 +642,13 @@ export const config = {
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
// Buchungskalender (Unterkuenfte, Preise, Sperren, Anfragen, Rechnungen, Einstellungen; scripts/unterkunft-setup.mjs)
ukUnterkuenfte: process.env.APPWRITE_COLLECTION_UK_UNTERKUENFTE || 'ukUnterkuenfte',
ukPreise: process.env.APPWRITE_COLLECTION_UK_PREISE || 'ukPreise',
ukSperren: process.env.APPWRITE_COLLECTION_UK_SPERREN || 'ukSperren',
ukAnfragen: process.env.APPWRITE_COLLECTION_UK_ANFRAGEN || 'ukAnfragen',
ukRechnungen: process.env.APPWRITE_COLLECTION_UK_RECHNUNGEN || 'ukRechnungen',
ukEinstellungen: process.env.APPWRITE_COLLECTION_UK_EINSTELLUNGEN || 'ukEinstellungen',
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
@@ -651,6 +669,7 @@ export const config = {
newsSetup,
termineSetup,
shopSetup,
unterkunftSetup,
zeitenSetup,
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
@@ -915,6 +934,47 @@ export const config = {
unbezahltLoeschTage: 30,
},
// Buchungskalender (Funktionsschluessel 'unterkunft'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
// Laufzeit); Schalter siehe server/services/unterkunft/schalter.js
unterkunft: {
// off (Vorgabe) | pilot (nur UNTERKUNFT_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(unterkunftEnabled) ? unterkunftEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.UNTERKUNFT_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Rechtstexte: Berliner Kalendertag (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
fassung: '2026-10-07',
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Anfrageformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.UNTERKUNFT_FORM_MIN_SEKUNDEN, 3),
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle. Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.UNTERKUNFT_MIN_ANTWORT_MS, 300),
// iCal-Import: Abrufintervall, Groessen-/Zeitgrenzen, Fehlermail nach so vielen Stunden anhaltenden Fehlern
icalIntervallMin: 15,
icalMaxBytes: 1_000_000,
icalTimeoutMs: 10_000,
icalFehlerStunden: 6,
// Buchbar sind so viele Monate in die Zukunft
horizontMonate: 18,
maxPosten: 30,
postenTextMax: 200,
nachrichtMax: 2000,
// Anfragen je Unterkunfts-Seite und Stunde (Missbrauchsbremse)
anfragenJeSiteStunde: 30,
// IP-Hashes werden nach so vielen Tagen geloescht
ipHashTage: 30,
// Unverbindliche Anfragen werden nach so vielen Monaten anonymisiert/geloescht
unverbindlichMonate: 6,
// Offene (neue) Anfragen verfallen nach so vielen Tagen
neuVerfallTage: 60,
// Anfragen werden so viele Jahre nach Abschluss anonymisiert; Rechnungen bleiben rechnungJahre Jahre
anonymisierenJahre: 3,
rechnungJahre: 8,
// Tage nach Abschalten der Funktion bis zur Loeschung der Daten
loeschTageNachAbschalten: 90,
},
// Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt
// config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js
zeiten: {
+26
View File
@@ -0,0 +1,26 @@
import { config } from '../../config.js'
/**
* Buchungskalender-Schalter (Funktionsschluessel 'unterkunft'): UNTERKUNFT_ENABLED = off (Vorgabe) | pilot | on.
* Liest config.unterkunft bei jedem Aufruf. Bei off laufen keine Jobs und keine Abfragen des Moduls.
*/
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
export function unterkunftModus() {
const mode = config.unterkunft.mode
return mode === 'pilot' || mode === 'on' ? mode : 'off'
}
/**
* Darf dieser Kunde den Buchungskalender nutzen? pilot = nur Adressen aus UNTERKUNFT_PILOT_EMAILS ('*' = alle; eine leere
* Liste laesst niemanden durch); on = alle Kunden; off = niemand.
*/
export function unterkunftFuerKunde(email) {
const modus = unterkunftModus()
if (modus === 'off') return false
if (modus === 'on') return true
const erlaubt = config.unterkunft.pilotEmails
if (erlaubt.includes('*')) return true
const adresse = String(email || '').trim().toLowerCase()
return adresse !== '' && erlaubt.includes(adresse)
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Buchungskalender: Appwrite-Schema der sechs Collections (Spezifikation 2026-10-07-buchungskalender-design.md, Abschnitt 3).
* Reine Daten, absichtlich ohne Import von server/config.js (wie shop/schema.js: test/support/schema.js liest sie vor
* useTestEnv()). Die Collection-IDs stehen in config.collections.<Name>; scripts/unterkunft-setup.mjs legt alles an.
* Kein Feld heisst data; JSON-Felder sind Text. ukEinstellungen: Dokument-ID = featureId.
*
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
* Index: { key, type: 'key', attributes: [...] }
* Datumswerte (dt) sind UTC (toISOString()); Kalendertage (von, bis, datum) sind Text YYYY-MM-DD (Berlin).
*/
const str = (key, size) => ({ key, type: 'string', size, required: false })
const int = (key) => ({ key, type: 'integer', required: false })
const bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
export const UNTERKUNFT_COLLECTIONS = {
ukUnterkuenfte: {
attributes: [
str('featureId', 36),
str('customerId', 36),
str('projectId', 36),
str('name', 120),
str('beschreibung', 500),
int('maxPersonen'),
int('mindestNaechte'),
str('checkIn', 5),
str('checkOut', 5),
int('sort'),
bool('aktiv'),
str('icalImporte', 4000),
str('icalExportToken', 64),
],
indexes: [index('idx_feature', 'featureId')],
},
ukPreise: {
attributes: [
str('unterkunftId', 36),
str('featureId', 36),
str('staffeln', 2000),
int('reinigungCent'),
int('personenInklusive'),
int('aufpreisPersonNachtCent'),
int('direktrabattProzent'),
str('steuerart', 20),
int('ustUebernachtung'),
int('ustReinigung'),
int('fristTage'),
str('stornobedingungen', 5000),
],
indexes: [index('idx_unterkunft', 'unterkunftId')],
},
ukSperren: {
attributes: [
str('unterkunftId', 36),
str('featureId', 36),
str('von', 10),
str('bis', 10),
str('quelle', 60),
str('notiz', 200),
],
indexes: [index('idx_unterkunft', 'unterkunftId'), index('idx_unterkunft_quelle', 'unterkunftId', 'quelle')],
},
ukAnfragen: {
attributes: [
str('featureId', 36),
str('customerId', 36),
str('projectId', 36),
str('unterkunftId', 36),
str('nummer', 12),
str('von', 10),
str('bis', 10),
int('personen'),
str('status', 12),
str('gast', 3000),
str('rechnungsadresse', 1500),
str('posten', 8000),
str('summen', 1000),
str('nachrichtVermieter', 2000),
dt('fristAt'),
str('tokenHash', 64),
str('zahlart', 8),
str('bezahltVia', 8),
dt('bezahltAt'),
str('stripeSessionId', 100),
str('stripePaymentIntentId', 100),
int('erstattetCent'),
str('stornoGrund', 500),
str('stornobedingungenText', 5000),
str('rechtstexteFassung', 20),
str('ipHash', 16),
dt('freigegebenAt'),
dt('gebuchtAt'),
dt('abgelehntAt'),
dt('abgelaufenAt'),
dt('storniertAt'),
str('rechnungId', 36),
dt('anonymisiertAt'),
],
indexes: [
index('idx_feature', 'featureId'),
index('idx_unterkunft', 'unterkunftId'),
index('idx_unterkunft_status', 'unterkunftId', 'status'),
index('idx_status', 'status'),
],
},
ukRechnungen: {
attributes: [
str('featureId', 36),
str('anfrageId', 36),
str('art', 8),
str('nummer', 24),
str('datum', 10),
int('bruttoCent'),
str('inhalt', 20000),
],
indexes: [index('idx_feature', 'featureId'), index('idx_anfrage', 'anfrageId'), index('idx_feature_nummer', 'featureId', 'nummer')],
},
ukEinstellungen: {
attributes: [
str('featureId', 36),
str('firma', 150),
str('inhaber', 150),
str('strasse', 150),
str('plz', 10),
str('ort', 100),
str('email', 200),
str('telefon', 50),
str('steuernummer', 30),
str('ustId', 20),
str('iban', 34),
str('bic', 11),
str('bank', 100),
str('rechnungsPraefix', 8),
str('rechnungZaehler', 100),
str('akzentfarbe', 7),
str('stripeAccountId', 40),
str('stripeStatus', 4000),
bool('onlineZahlung'),
dt('rechnungAuftragAt'),
str('anfrage', 2000),
str('anfrageStatus', 16),
dt('anfrageAt'),
dt('anfrageEntschiedenAt'),
str('anfrageEntschiedenVon', 120),
str('anfrageBegruendung', 1000),
dt('icalFehlerMailAt'),
],
indexes: [index('idx_feature', 'featureId')],
},
}
/** Je Collection die datetime-Attribute (der Appwrite-Nachbau der Tests normalisiert sie). */
export const UNTERKUNFT_DATETIMES = Object.fromEntries(
Object.entries(UNTERKUNFT_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]),
)