ZEITEN_ENABLED/ZEITEN_PILOT_EMAILS/ZEITEN_KEIN_NEUBAU als config.zeiten (neben config.termine), zeitenSetup (30 € einmalig), Collection-IDs oeffnungszeiten und zeitenErinnerungen, Schema der beiden Collections plus fünf neue portalFeatures-Felder, scripts/zeiten-setup.mjs (idempotent, --check), schemaKeys kennt die neuen Collections und Felder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
607 lines
30 KiB
JavaScript
607 lines
30 KiB
JavaScript
import 'dotenv/config'
|
||
import path from 'node:path'
|
||
|
||
/** Einzige erlaubte Datenbank (Ticketsystem). Keine separate Portal-DB mehr. */
|
||
export const WOMS_DATABASE_ID = 'woms-database'
|
||
|
||
const DEPRECATED_DATABASE_IDS = new Set([
|
||
'6943bf0e0003291f8c35', // frühere Portal-/Legacy-DB
|
||
])
|
||
|
||
function resolveDatabaseId() {
|
||
const fromEnv = (process.env.APPWRITE_DATABASE_ID || '').trim()
|
||
if (!fromEnv || fromEnv === WOMS_DATABASE_ID) return WOMS_DATABASE_ID
|
||
if (DEPRECATED_DATABASE_IDS.has(fromEnv)) {
|
||
console.warn(
|
||
`[config] Veraltete APPWRITE_DATABASE_ID "${fromEnv}" – es wird nur ${WOMS_DATABASE_ID} verwendet.`
|
||
)
|
||
return WOMS_DATABASE_ID
|
||
}
|
||
console.warn(
|
||
`[config] APPWRITE_DATABASE_ID="${fromEnv}" ignoriert – Portal nutzt ausschließlich ${WOMS_DATABASE_ID}.`
|
||
)
|
||
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()
|
||
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
|
||
|
||
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
|
||
function commaList(raw, fallback) {
|
||
const list = String(raw || '')
|
||
.split(',')
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean)
|
||
return list.length ? list : fallback
|
||
}
|
||
|
||
// 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 = {
|
||
wartung: {
|
||
key: 'wartung',
|
||
name: 'WEBklar Wartungs-Abo',
|
||
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
|
||
amountCents: 5000,
|
||
// Version 4 (Rechtsrunde 28.09.2026): beliebig viele E-Mail-Postfächer statt 1 E-Mail-Postfach (F07;
|
||
// Fix round 1, item 11: ausgeschrieben "E-Mail-Postfächer" statt bloß "Postfächer")
|
||
description:
|
||
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele E-Mail-Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
|
||
statementDescriptor: 'WEBKLAR WARTUNG',
|
||
priceNickname: 'Wartungs-Abo monatlich',
|
||
minTermMonths: 12,
|
||
minutesPerMonth: 60,
|
||
// Testrabatt: id passend zur amount_off-Regel (amountCents - 50), siehe global-constraints.md
|
||
testCouponId: 'WEBKLAR-TEST-050',
|
||
// Leistungen fuers Kundenportal (Vergleich, Bestellseite): [Titel, Detail], Spec 3.1 / Fassung 4
|
||
features: [
|
||
['Betrieb & Pflege Ihrer Website bzw. Web-Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
|
||
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
|
||
['beliebig viele Postfächer', 'zusammen 5 GB Speicher'],
|
||
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
|
||
],
|
||
},
|
||
hosting: {
|
||
key: 'hosting',
|
||
name: 'WEBklar Hosting-Abo',
|
||
lookupKey: 'webklar_hosting_monatlich_2000',
|
||
amountCents: 2000,
|
||
description:
|
||
'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
|
||
statementDescriptor: 'WEBKLAR HOSTING',
|
||
priceNickname: 'Hosting-Abo monatlich',
|
||
minTermMonths: 12,
|
||
minutesPerMonth: 0,
|
||
testCouponId: 'WEBKLAR-TEST-050-HOSTING',
|
||
// Fix round 1, Important 3: row 1 war wörtlich von Wartung kopiert und suggerierte Website-Pflege
|
||
// (die es im Hosting-Abo nicht gibt); row 4 nennt jetzt die Abrechnung in 15-Minuten-Schritten.
|
||
features: [
|
||
['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, SSL, Updates von Betriebssystem und Server-Software, tägliche Datensicherung, Überwachung'],
|
||
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
|
||
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
|
||
['Keine Pflege der Website', 'Pflege und Updates der Website nur im Wartungs-Abo'],
|
||
['Änderungen nur nach Freigabe', '100 € pro Stunde in 15-Minuten-Schritten (gemäß § 19 UStG ohne Umsatzsteuer)'],
|
||
],
|
||
},
|
||
}
|
||
|
||
// E-Mail-Zusatzspeicher: zweite Position (Stückzahl) im selben Stripe-Abo
|
||
const mailStorage = {
|
||
key: 'mailStorage',
|
||
name: 'WEBklar E-Mail-Zusatzspeicher 5 GB',
|
||
lookupKey: 'webklar_mailspeicher_5gb_monatlich_100',
|
||
amountCents: 100,
|
||
gbPerUnit: 5,
|
||
includedGb: 5,
|
||
maxUnits: 10,
|
||
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
|
||
// Hetzner-Paket (konsoleH), auf dem alle Postfaecher liegen: Gesamtgroesse und die Schwelle, ab der
|
||
// der Admin-Reiter "E-Mail-Speicher" rot warnt (Task 13)
|
||
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
|
||
}
|
||
|
||
// News-Pinnwand: einmalige Einrichtung (Einmalpreis, kein Abo)
|
||
const newsSetup = {
|
||
key: 'newsSetup',
|
||
name: 'News-Pinnwand – Einrichtung',
|
||
amountCents: 3000,
|
||
lookupKey: 'webklar_news_einrichtung_3000',
|
||
oneTime: true,
|
||
priceNickname: 'News-Einrichtung 30 €',
|
||
}
|
||
|
||
// Terminplaner: einmalige Einrichtung (Einmalpreis, kein Abo); der Terminplaner selbst laeuft, solange das Wartungs-Abo laeuft
|
||
const termineSetup = {
|
||
key: 'termineSetup',
|
||
name: 'Terminplaner – Einrichtung',
|
||
amountCents: 14900,
|
||
lookupKey: 'webklar_termine_einrichtung_14900',
|
||
oneTime: true,
|
||
priceNickname: 'Terminplaner-Einrichtung 149 €',
|
||
}
|
||
|
||
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
|
||
const zeitenSetup = {
|
||
key: 'zeitenSetup',
|
||
name: 'Öffnungszeiten live – Einrichtung',
|
||
amountCents: 3000,
|
||
lookupKey: 'webklar_zeiten_einrichtung_3000',
|
||
oneTime: true,
|
||
priceNickname: 'Öffnungszeiten-Einrichtung 30 €',
|
||
}
|
||
|
||
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
|
||
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
|
||
|
||
const ENABLE_BANKING_API_BASE = 'https://api.enablebanking.com'
|
||
const enableBankingAppId = (process.env.ENABLE_BANKING_APP_ID || '').trim()
|
||
const enableBankingKeyPath = (process.env.ENABLE_BANKING_KEY_PATH || '').trim()
|
||
|
||
/**
|
||
* Basisadresse der Enable-Banking-API. ENABLE_BANKING_API_BASE (nur fuer Tests: Nachbau) gilt nur fuer
|
||
* 127.0.0.1 oder localhost; jeder andere Wert wird ignoriert, damit ein verirrter Wert den signierten
|
||
* Zugriff (JWT) nie an einen fremden Server schickt.
|
||
*/
|
||
export function resolveEnableBankingApiBase(value) {
|
||
const raw = String(value || '').trim()
|
||
if (!raw) return ENABLE_BANKING_API_BASE
|
||
try {
|
||
const url = new URL(raw)
|
||
if (['http:', 'https:'].includes(url.protocol) && ['127.0.0.1', 'localhost'].includes(url.hostname)) return url.origin
|
||
} catch {
|
||
// keine gueltige Adresse: Standard
|
||
}
|
||
return ENABLE_BANKING_API_BASE
|
||
}
|
||
|
||
// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn
|
||
const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'
|
||
|
||
const ticketProjektName = (project) => project?.projectName || project?.subdomain || ''
|
||
|
||
/**
|
||
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
|
||
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
|
||
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
|
||
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
|
||
* Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js.
|
||
* ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets
|
||
* (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument).
|
||
*/
|
||
const features = {
|
||
news: {
|
||
key: 'news',
|
||
product: 'newsSetup',
|
||
label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' },
|
||
priceLabel: '30 €',
|
||
setupEstimate: 'meist innerhalb von 1–2 Wochen',
|
||
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB)
|
||
consent: {
|
||
version: '2026-09-30',
|
||
knopf: 'Zahlungspflichtig bestellen · 30 €',
|
||
items: [
|
||
{
|
||
id: 'beginn',
|
||
haken: true,
|
||
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
|
||
},
|
||
],
|
||
},
|
||
leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' },
|
||
avv: null,
|
||
widerruf: null,
|
||
returnHash: 'news',
|
||
returnProjekt: false,
|
||
ticket: {
|
||
topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(),
|
||
details: async (ctx) => {
|
||
const { feature, doc, project } = ctx || {}
|
||
const siteKey = doc?.siteKey || ''
|
||
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
|
||
return [
|
||
`Funktion: ${feature?.key || 'news'}`,
|
||
`Projekt: ${ticketProjektName(project)}`,
|
||
`Domain: ${domain}`,
|
||
`siteKey: ${siteKey}`,
|
||
`Einbau: <div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`,
|
||
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
|
||
].join('\n')
|
||
},
|
||
},
|
||
mails: {
|
||
funktion: 'News-Pinnwand',
|
||
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
|
||
knopf: 'Beitrag schreiben',
|
||
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
|
||
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
|
||
preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo',
|
||
anleitung: false,
|
||
},
|
||
requiresWartungsAbo: false,
|
||
ticketFreischaltung: true,
|
||
disabledMessage:
|
||
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
|
||
orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.',
|
||
},
|
||
termine: {
|
||
key: 'termine',
|
||
product: 'termineSetup',
|
||
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
|
||
priceLabel: '149 €',
|
||
setupEstimate: 'meist innerhalb von 1–2 Wochen',
|
||
// Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F
|
||
consent: {
|
||
version: 'ENTWURF-2026-10-03',
|
||
knopf: 'Zahlungspflichtig bestellen · 149 €',
|
||
items: [
|
||
{ id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' },
|
||
{ id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' },
|
||
{
|
||
id: 'beginn',
|
||
haken: true,
|
||
text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.',
|
||
},
|
||
{
|
||
id: 'information',
|
||
haken: true,
|
||
text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.',
|
||
},
|
||
],
|
||
},
|
||
leistungsbeschreibung: { version: 'ENTWURF', url: '' },
|
||
avv: { version: 'ENTWURF', url: '' },
|
||
widerruf: null,
|
||
returnHash: 'termine',
|
||
returnProjekt: true,
|
||
ticket: {
|
||
topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`,
|
||
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
|
||
details: async (ctx) => {
|
||
const { termineTicketDetails } = await import('./services/termine/ticketDetails.js')
|
||
return termineTicketDetails(ctx)
|
||
},
|
||
},
|
||
mails: {
|
||
funktion: 'Terminplaner',
|
||
formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' },
|
||
knopf: 'Termine öffnen',
|
||
// Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde
|
||
danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.',
|
||
bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.',
|
||
preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`,
|
||
anleitung: true,
|
||
},
|
||
requiresWartungsAbo: true,
|
||
ticketFreischaltung: true,
|
||
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
|
||
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
|
||
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
|
||
},
|
||
}
|
||
|
||
export const config = {
|
||
port: Number(process.env.PORT) || 3000,
|
||
sessionSecret: process.env.SESSION_SECRET || '',
|
||
cookieName: process.env.SESSION_COOKIE_NAME || 'webklar_portal_session',
|
||
allowedCustomerStatuses: (process.env.ALLOWED_CUSTOMER_STATUSES || 'active')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
|
||
appwrite: {
|
||
endpoint: process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1',
|
||
projectId: process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05',
|
||
databaseId: resolveDatabaseId(),
|
||
apiKey: process.env.APPWRITE_API_KEY || '',
|
||
},
|
||
|
||
collections: {
|
||
customers: process.env.APPWRITE_COLLECTION_CUSTOMERS || 'customers',
|
||
customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess',
|
||
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
|
||
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
|
||
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
|
||
// Zustimmungsnachweis zur Bestellung einer Funktion (nur Server-Key; scripts/feature-setup.mjs)
|
||
featureConsents: process.env.APPWRITE_COLLECTION_FEATURE_CONSENTS || 'featureConsents',
|
||
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
|
||
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
|
||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
|
||
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
|
||
customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog',
|
||
// Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten)
|
||
bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections',
|
||
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',
|
||
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
|
||
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
|
||
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
|
||
},
|
||
|
||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||
// Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail
|
||
womsBaseUrl,
|
||
|
||
// Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login)
|
||
pushTime: {
|
||
secret: process.env.PUSH_TIME_SECRET || process.env.SESSION_SECRET || '',
|
||
ttlDays: 14,
|
||
},
|
||
|
||
products,
|
||
mailStorage,
|
||
newsSetup,
|
||
termineSetup,
|
||
zeitenSetup,
|
||
ustHinweis,
|
||
// Register der kaufbaren Funktionen (siehe oben)
|
||
features,
|
||
// Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der
|
||
// Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit)
|
||
funktionen: {
|
||
get setupEstimate() {
|
||
return config.features.news.setupEstimate
|
||
},
|
||
get consentText() {
|
||
return config.features.news.consent.items[0].text
|
||
},
|
||
},
|
||
|
||
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
||
// Die Werte kommen aus dem Produktkatalog (products.wartung).
|
||
stripe: {
|
||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||
// Nur für Tests: Stripe-Nachbau statt api.stripe.com (z. B. http://127.0.0.1:12111)
|
||
apiBase: process.env.STRIPE_API_BASE || '',
|
||
priceLookupKey: products.wartung.lookupKey,
|
||
priceAmountCents: products.wartung.amountCents,
|
||
productName: products.wartung.name,
|
||
statementDescriptor: products.wartung.statementDescriptor,
|
||
// Generisches Logo, allen drei Stripe-Produkten mitgegeben (ensurePrice); ein Katalogeintrag
|
||
// kann das mit einem eigenen "image"-Feld ueberschreiben
|
||
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
|
||
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
|
||
invoiceFooter:
|
||
process.env.STRIPE_INVOICE_FOOTER ||
|
||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
|
||
},
|
||
|
||
// Finom-Geschaeftskonto lesend ueber Enable Banking (PSD2). Der private Schluessel liegt als Datei auf dem
|
||
// Server (nie im Repo); der Client liest ihn bei jedem Aufruf, ein Schluesseltausch wirkt ohne Neustart.
|
||
enableBanking: {
|
||
appId: enableBankingAppId,
|
||
keyPath: enableBankingKeyPath,
|
||
apiBase: resolveEnableBankingApiBase(process.env.ENABLE_BANKING_API_BASE),
|
||
redirectUrl: `${womsBaseUrl}/finanzen/bank-callback`,
|
||
aspsp: { name: 'Finom', country: 'DE' },
|
||
enabled: Boolean(enableBankingAppId && enableBankingKeyPath),
|
||
},
|
||
|
||
abo: {
|
||
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
|
||
termsVersion: '2026-09-30-v5',
|
||
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
|
||
minTermMonths: products.wartung.minTermMonths,
|
||
minutesPerMonth: products.wartung.minutesPerMonth,
|
||
creditValidityMonths: 12,
|
||
usageRoundingMinutes: 15,
|
||
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js
|
||
// Pilotbetrieb: nur diese Kunden-E-Mails duerfen bestellen (leer = alle)
|
||
pilotEmails: (process.env.ABO_PILOT_EMAILS || '')
|
||
.split(',')
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
// Testkonten zahlen nur den Stripe-Mindestbetrag (0,50 €) ueber einen Rabatt auf den echten Preis
|
||
testEmails: (process.env.ABO_TEST_EMAILS || '')
|
||
.split(',')
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
// Fix round 1, Minor 8: einzige Quelle ist der Katalog (products.wartung.testCouponId); Hosting
|
||
// hat sein eigenes unter products.hosting.testCouponId
|
||
testCouponId: products.wartung.testCouponId,
|
||
},
|
||
|
||
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
|
||
offers: {
|
||
maxMonths: 12,
|
||
defaultValidityDays: 30,
|
||
maxValidityDays: 366,
|
||
},
|
||
|
||
// Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet
|
||
takeover: {
|
||
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert).
|
||
// Entwurf vom 27.09.2026 (Gutschrift fuer beide Abos); das Freigabedatum wird beim Livegang eingetragen.
|
||
conditionsVersion: '2026-09-30',
|
||
// Nachweise liegen ausserhalb von public/ (gitignored, ueberlebt Deploys wie preview-data/)
|
||
uploadDir: process.env.TAKEOVER_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'takeover-credits'),
|
||
maxFiles: 3,
|
||
maxFileBytes: 8 * 1024 * 1024,
|
||
maxTotalBytes: 10 * 1024 * 1024,
|
||
// Plausibilitaetsgrenze fuer eine einzelne Gutschrift (Tippfehler abfangen)
|
||
maxAmountCents: 100000,
|
||
maxOpenPerCustomer: 3,
|
||
// Nachweise abgelehnter Antraege werden nach dieser Frist geloescht (Datenschutzerklaerung Abschnitt 7)
|
||
rejectedRetentionDays: 183,
|
||
},
|
||
|
||
// News-Pinnwand: Fotos liegen ausserhalb von public/ (gitignored, ueberleben Deploys)
|
||
news: {
|
||
uploadDir: process.env.NEWS_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'news'),
|
||
},
|
||
|
||
// Chat-Anhaenge (Bilder, Dateien, Kamerafotos, Sprachnachrichten): liegen ausserhalb von public/
|
||
// (gitignored, ueberleben Deploys wie preview-data/)
|
||
chat: {
|
||
uploadDir: process.env.CHAT_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'chat'),
|
||
maxFileBytes: 25 * 1024 * 1024,
|
||
maxFilesPerMessage: 10,
|
||
// Uploads werden im Speicher gepuffert: hoechstens so viele gleichzeitig, weitere warten
|
||
maxParallelUploads: 4,
|
||
// Missbrauchsschutz: Uploads je Kunde und Stunde, Gesamtspeicher je Kunde, freier Platz auf dem Server
|
||
maxUploadsPerHour: 200,
|
||
maxBytesPerCustomer: 1024 * 1024 * 1024,
|
||
minFreeDiskBytes: 2 * 1024 * 1024 * 1024,
|
||
// Hochgeladen, aber nie abgeschickt: nach dieser Zeit loeschen (Datenschutzerklaerung Abschnitt 6)
|
||
pendingTtlHours: 24,
|
||
},
|
||
|
||
smtp: {
|
||
host: process.env.SMTP_HOST || '',
|
||
port: Number(process.env.SMTP_PORT) || 587,
|
||
secure: (process.env.SMTP_SECURE || 'false') === 'true',
|
||
user: process.env.SMTP_USERNAME || '',
|
||
pass: process.env.SMTP_PASSWORD || '',
|
||
from: process.env.SMTP_FROM || process.env.SMTP_USERNAME || '',
|
||
// Antworten auf Kundenmails landen hier statt im Versandpostfach (noreply@)
|
||
replyTo: process.env.SMTP_REPLY_TO || '',
|
||
},
|
||
|
||
chatNotify: {
|
||
emails: (process.env.CHAT_NOTIFY_EMAILS || 'kenso@webklar.com,andrej@webklar.com')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
throttleMin: Number(process.env.CHAT_NOTIFY_THROTTLE_MIN) || 15,
|
||
},
|
||
|
||
gitea: {
|
||
webhookToken: process.env.GITEA_WEBHOOK_TOKEN || '',
|
||
baseUrl: process.env.GITEA_BASE_URL || 'https://git.webklar.com',
|
||
apiToken: process.env.GITEA_API_TOKEN || '',
|
||
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
|
||
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
|
||
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
|
||
// Ziel-Organisation des Umzugs (siehe docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md)
|
||
org: process.env.GITEA_ORG || 'WEBklar',
|
||
// Login des System-Kontos, das automatische Commits/Pushes ausfuehrt (kein Arbeitsblatt dafuer)
|
||
systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system',
|
||
// Interne Repos, die trotz Zuweisung nicht nach GITEA_ORG umziehen
|
||
keepInPlace: (
|
||
process.env.GITEA_KEEP_IN_PLACE ||
|
||
'ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich'
|
||
)
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
// Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen
|
||
backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30,
|
||
// Zeitlimit fuer jeden Gitea-API-Aufruf (giteaFetch, fetchPreviewConfig); verhindert ein
|
||
// haengendes Portal, wenn Gitea nicht antwortet (Controller-Ruling final-fix Punkt 1).
|
||
timeoutMs: Number(process.env.GITEA_TIMEOUT_MS) || 30000,
|
||
},
|
||
|
||
cors: {
|
||
allowedOrigins: (process.env.ADMIN_CORS_ORIGINS || 'https://ticket.webklar.com,http://localhost:5173')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
},
|
||
|
||
preview: {
|
||
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
|
||
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
|
||
traefikDynamicDir: process.env.TRAEFIK_DYNAMIC_DIR || '',
|
||
sessionSecret:
|
||
process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '',
|
||
cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com',
|
||
sessionMaxAge: Number(process.env.PREVIEW_SESSION_MAX_AGE || 60 * 60 * 24 * 7),
|
||
},
|
||
|
||
previewRunner: {
|
||
enabled: process.env.PREVIEW_RUNNER === 'on',
|
||
url: process.env.PREVIEW_RUNNER_URL || 'http://webklar-preview-runner:8080',
|
||
token: process.env.PREVIEW_RUNNER_TOKEN || '',
|
||
logDir: process.env.PREVIEW_LOG_DIR || path.join(process.cwd(), 'upload-data', 'preview-logs'),
|
||
},
|
||
|
||
analyse: {
|
||
enabled: process.env.ANALYSE_ENABLED === 'on',
|
||
url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091',
|
||
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
|
||
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
|
||
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,
|
||
// Mail-Fairness (outbox.js): hoechstens so viele Mails je Funktion und Stunde (gleitendes Fenster, unter mailsProStunde)
|
||
mailsProFunktion: 40,
|
||
// Anteil der Stunde, der Vorrang- und Normal-Mails vorbehalten bleibt: nachrangige Mails (G05, K20, K21) nehmen hoechstens
|
||
// max(1, floor(mailsProStunde x (1 - mailsReserve))) Plaetze
|
||
mailsReserve: 0.25,
|
||
// Eintraege je Abfrage der Mail-Warteschlange (verarbeite, Minutenjob): hoechstens so viele fuer eine Klasse und Runde
|
||
mailListe: 100,
|
||
// Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und
|
||
// nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag
|
||
loeschDeckel: 2000,
|
||
// Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen)
|
||
loeschDeckelGesamt: 20000,
|
||
// Hoechstens so viele alte Belegungszellen (bis mehr als einen Tag vorbei) loescht der Zellen-Sweep des Tagesjobs je Lauf; der Rest folgt am naechsten Tag
|
||
zellenDeckel: 5000,
|
||
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
|
||
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
|
||
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
|
||
// eine Adresse oder Buchung bekannt ist (Zeitunterschied). Tests setzen 0.
|
||
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
|
||
},
|
||
|
||
// 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: {
|
||
// off (Vorgabe) | pilot (nur ZEITEN_PILOT_EMAILS) | on (alle Kunden)
|
||
mode: ['pilot', 'on'].includes(zeitenEnabled) ? zeitenEnabled : 'off',
|
||
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||
pilotEmails: commaList(process.env.ZEITEN_PILOT_EMAILS, []),
|
||
// Subdomains, fuer die der Neubau nie angestossen wird (Einbau von Hand); ZEITEN_KEIN_NEUBAU ist eine
|
||
// Komma-Liste, die Vorgabe ist emmas-schatzkiste
|
||
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
|
||
},
|
||
|
||
invoiceNinja: {
|
||
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
|
||
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
|
||
},
|
||
}
|
||
|
||
export function assertServerConfig() {
|
||
if (!config.sessionSecret || config.sessionSecret.length < 32) {
|
||
console.warn('[config] SESSION_SECRET fehlt oder ist zu kurz (min. 32 Zeichen).')
|
||
}
|
||
if (!config.pushTime.secret || config.pushTime.secret.length < 32) {
|
||
console.warn('[config] PUSH_TIME_SECRET (bzw. SESSION_SECRET) fehlt oder ist zu kurz – Zeit-Links aus Push-Mails funktionieren nicht.')
|
||
}
|
||
if (!config.appwrite.apiKey) {
|
||
console.warn('[config] APPWRITE_API_KEY fehlt – DB-Zugriff und Webhook schlagen fehl.')
|
||
}
|
||
}
|