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() // 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 €', } // 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:
+ 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', }, 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, 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), }, 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.') } }