Terminplaner: Schalter, Konfiguration, Schema
Schalter TERMINE_ENABLED (off|pilot|on) mit TERMINE_PILOT_EMAILS, config.termine und die vier Collection-IDs, Schema der Termine-Collections als reine Daten (server/services/termine/schema.js), Setup-Skript scripts/termine-setup.mjs mit --check und schemaKeys() fuer den Appwrite-Nachbau der Tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f63165c158
commit
334dc1e669
7 files changed
+714
-1
No files matched your search
@@ -23,6 +23,14 @@ function resolveDatabaseId() {
|
||||
return WOMS_DATABASE_ID
|
||||
}
|
||||
|
||||
/** Zahl >= 0 aus einer Umgebungsvariablen; leer, keine Zahl oder negativ ergibt den Vorgabewert. */
|
||||
function nonNegativeNumber(raw, fallback) {
|
||||
const value = Number(raw)
|
||||
return String(raw ?? '').trim() !== '' && Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
|
||||
|
||||
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
|
||||
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
|
||||
const products = {
|
||||
@@ -160,6 +168,11 @@ export const config = {
|
||||
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
|
||||
financeSettings: process.env.APPWRITE_COLLECTION_FINANCE_SETTINGS || 'financeSettings',
|
||||
financeFixedCosts: process.env.APPWRITE_COLLECTION_FINANCE_FIXED_COSTS || 'financeFixedCosts',
|
||||
// Terminplaner (Einstellungen, Buchungen, Belegungszellen, Mail-Warteschlange; scripts/termine-setup.mjs)
|
||||
termineConfig: process.env.APPWRITE_COLLECTION_TERMINE_CONFIG || 'termineConfig',
|
||||
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
|
||||
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
|
||||
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
@@ -360,6 +373,23 @@ export const config = {
|
||||
avvVersion: '2026-10-02',
|
||||
},
|
||||
|
||||
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
|
||||
// Laufzeit); Schalter siehe server/services/termine/schalter.js
|
||||
termine: {
|
||||
// off (Vorgabe) | pilot (nur TERMINE_PILOT_EMAILS) | on (alle Kunden)
|
||||
mode: ['pilot', 'on'].includes(termineEnabled) ? termineEnabled : 'off',
|
||||
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||||
pilotEmails: (process.env.TERMINE_PILOT_EMAILS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
holdMinutes: 30,
|
||||
zelleMin: 5,
|
||||
mailsProStunde: 200,
|
||||
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
|
||||
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
|
||||
},
|
||||
|
||||
invoiceNinja: {
|
||||
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
|
||||
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Terminplaner-Schalter (Funktionsschluessel 'termine'): TERMINE_ENABLED = off (Vorgabe) | pilot | on.
|
||||
* Liest config.termine bei jedem Aufruf. Bei off laufen keine Termine-Jobs und keine Termine-Abfragen.
|
||||
*/
|
||||
|
||||
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
|
||||
export function termineModus() {
|
||||
const mode = config.termine.mode
|
||||
return mode === 'pilot' || mode === 'on' ? mode : 'off'
|
||||
}
|
||||
|
||||
export function termineAus() {
|
||||
return termineModus() === 'off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Kunde den Terminplaner nutzen? pilot = nur Adressen aus TERMINE_PILOT_EMAILS ('*' = alle; eine leere
|
||||
* Liste laesst niemanden durch); on = alle Kunden (TP1: pilot ohne E-Mail-Filter); off = niemand.
|
||||
*/
|
||||
export function termineFuerKunde(email) {
|
||||
const modus = termineModus()
|
||||
if (modus === 'off') return false
|
||||
if (modus === 'on') return true
|
||||
const erlaubt = config.termine.pilotEmails
|
||||
if (erlaubt.includes('*')) return true
|
||||
const adresse = String(email || '').trim().toLowerCase()
|
||||
return adresse !== '' && erlaubt.includes(adresse)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Terminplaner: Appwrite-Schema der vier Collections (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2).
|
||||
* Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(),
|
||||
* ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in
|
||||
* config.collections.<Name>; scripts/termine-setup.mjs legt alles an.
|
||||
*
|
||||
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
|
||||
* Index: { key, type: 'key', attributes: [...] }
|
||||
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen.
|
||||
*/
|
||||
|
||||
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 TERMINE_COLLECTIONS = {
|
||||
// Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Das Feld heisst einstellung,
|
||||
// nicht data: appwriteAdmin.formatRequestBody haelt Bodies mit data fuer fertig verpackt.
|
||||
termineConfig: {
|
||||
attributes: [
|
||||
str('customerId', 64),
|
||||
str('projectId', 64),
|
||||
str('einstellung', 200000),
|
||||
int('version'),
|
||||
dt('updatedAt'),
|
||||
str('updatedBy', 100),
|
||||
],
|
||||
indexes: [],
|
||||
},
|
||||
|
||||
// Eine Buchung je Dokument (ID randomUUID)
|
||||
termineBookings: {
|
||||
attributes: [
|
||||
str('featureId', 64),
|
||||
str('customerId', 64),
|
||||
str('projectId', 64),
|
||||
str('leistungId', 64),
|
||||
str('leistungName', 120),
|
||||
str('ressourceId', 64),
|
||||
str('ressourceName', 80),
|
||||
dt('startsAt'),
|
||||
dt('endsAt'),
|
||||
dt('blockEndsAt'),
|
||||
str('status', 16),
|
||||
str('quelle', 16),
|
||||
str('gastName', 100),
|
||||
str('gastEmail', 254),
|
||||
str('gastTelefon', 40),
|
||||
str('gastNachricht', 500),
|
||||
str('gastZusatz', 2000),
|
||||
bool('erinnerung'),
|
||||
dt('erinnerungGesendetAt'),
|
||||
dt('heldUntil'),
|
||||
str('verifyHash', 64),
|
||||
str('manageHash', 64),
|
||||
dt('bestaetigtAt'),
|
||||
dt('angenommenAt'),
|
||||
dt('abgesagtAt'),
|
||||
str('abgesagtVon', 16),
|
||||
str('absageGrund', 300),
|
||||
dt('widerrufenAt'),
|
||||
str('nachweis', 4000),
|
||||
int('preisCent'),
|
||||
str('preisArt', 8),
|
||||
str('buchungsart', 8),
|
||||
bool('verbraucher'),
|
||||
str('zahlart', 8),
|
||||
str('zahlStatus', 16),
|
||||
str('stripeSessionId', 128),
|
||||
str('stripePaymentIntent', 64),
|
||||
int('icsSequenz'),
|
||||
dt('createdAt'),
|
||||
dt('updatedAt'),
|
||||
],
|
||||
indexes: [
|
||||
index('idx_feature_start', 'featureId', 'startsAt'),
|
||||
index('idx_status_held', 'status', 'heldUntil'),
|
||||
index('idx_feature_email', 'featureId', 'gastEmail'),
|
||||
index('idx_verify', 'verifyHash'),
|
||||
index('idx_manage', 'manageHash'),
|
||||
index('idx_status_start', 'status', 'startsAt'),
|
||||
],
|
||||
},
|
||||
|
||||
// Belegungszellen gegen Doppelbuchung; Dokument-ID c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)
|
||||
termineClaims: {
|
||||
attributes: [str('featureId', 64), str('ressourceId', 64), str('bookingId', 64), dt('zelle'), dt('bis')],
|
||||
indexes: [index('idx_booking', 'bookingId'), index('idx_bis', 'bis')],
|
||||
},
|
||||
|
||||
// Mail-Warteschlange (einziger Sendepfad); enthaelt personenbezogene Daten, daher kurze Aufbewahrung
|
||||
termineOutbox: {
|
||||
attributes: [
|
||||
str('featureId', 64),
|
||||
str('bookingId', 64),
|
||||
str('art', 16),
|
||||
str('an', 254),
|
||||
str('fromName', 120),
|
||||
str('replyTo', 254),
|
||||
str('subject', 300),
|
||||
str('html', 100000),
|
||||
str('text', 50000),
|
||||
str('ics', 20000),
|
||||
str('icsMethod', 8),
|
||||
str('status', 16),
|
||||
int('versuche'),
|
||||
dt('naechsterVersuch'),
|
||||
dt('gesendetAt'),
|
||||
str('fehler', 300),
|
||||
dt('createdAt'),
|
||||
],
|
||||
indexes: [index('idx_status_next', 'status', 'naechsterVersuch')],
|
||||
},
|
||||
}
|
||||
|
||||
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
|
||||
export const TERMINE_DATETIMES = Object.fromEntries(
|
||||
Object.entries(TERMINE_COLLECTIONS).map(([name, { attributes }]) => [
|
||||
name,
|
||||
attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key),
|
||||
]),
|
||||
)
|
||||
Reference in new issue
Block a user