I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell bestaetigte), loescheShopDaten loescht das Archiv mit. I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
843 lines
42 KiB
JavaScript
843 lines
42 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 shopEnabled = (process.env.SHOP_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 €',
|
||
}
|
||
|
||
// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect)
|
||
const shopSetup = {
|
||
key: 'shopSetup',
|
||
name: 'Webshop – Einrichtung',
|
||
amountCents: 29900,
|
||
lookupKey: 'webklar_shop_einrichtung_29900',
|
||
oneTime: true,
|
||
priceNickname: 'Webshop-Einrichtung 299 €',
|
||
}
|
||
|
||
// 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 || ''
|
||
|
||
/**
|
||
* Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen).
|
||
* Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach
|
||
* dem Test) oder undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. null gilt ebenso,
|
||
* ausser bei nullIstWert (Widerrufsbelehrung): dort heisst null "keine Unterlage" (so setzen Tests eine Bestellung ohne sie).
|
||
*/
|
||
function termineRechtsunterlage(slug, { nullIstWert = false } = {}) {
|
||
let ueberschrieben // undefined = Standard
|
||
const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.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 termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner')
|
||
const termineAvv = termineRechtsunterlage('avv-terminplaner')
|
||
const termineWiderruf = termineRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
|
||
|
||
const TERMINE_PREIS = '149 €'
|
||
const TERMINE_DAUER = 'meist innerhalb von 1–2 Wochen'
|
||
|
||
/**
|
||
* Bestelltexte des Terminplaners (Task F7): vier Haken, alle Besteller (D1), ohne Adressen im Text (die versionierten Links
|
||
* zeigt die Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.termine.avvVersion; die
|
||
* dynamischen Teile (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text.
|
||
* Die Haken werden je Fassung einmal gebaut und danach unveraendert herausgegeben (Tests aendern einzelne Texte und stellen sie
|
||
* zurueck); aendert sich die Fassung zur Laufzeit, entstehen sie neu. Bei Textaenderung neues Fassungsdatum und neuen Hash im
|
||
* Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und die Pflichtinformationen sind Entwurf bis
|
||
* zur Kanzlei (Kanzlei-Paket, Fragen Q3, Q5, Q19, Q20).
|
||
*/
|
||
function termineConsent() {
|
||
let versionGesetzt // undefined = config.termine.avvVersion
|
||
let gebaut = null // { version, items }
|
||
const version = () => versionGesetzt ?? config.termine.avvVersion
|
||
const items = (v) => [
|
||
{
|
||
id: 'unterlagen',
|
||
haken: true,
|
||
text: `Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${v} zu.`,
|
||
},
|
||
{
|
||
id: 'gesundheit',
|
||
haken: true,
|
||
text: 'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).',
|
||
},
|
||
{
|
||
id: 'beginn',
|
||
haken: true,
|
||
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners 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 Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${TERMINE_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${TERMINE_DAUER}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
|
||
},
|
||
]
|
||
return {
|
||
get version() {
|
||
return version()
|
||
},
|
||
set version(wert) {
|
||
versionGesetzt = wert === undefined || wert === null || wert === config.termine.avvVersion ? undefined : wert
|
||
},
|
||
knopf: `Zahlungspflichtig bestellen · ${TERMINE_PREIS}`,
|
||
get items() {
|
||
const v = version()
|
||
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
|
||
return gebaut.items
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem
|
||
* Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab.
|
||
*/
|
||
function shopRechtsunterlage(slug, { nullIstWert = false } = {}) {
|
||
let ueberschrieben // undefined = Standard
|
||
const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.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 shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop')
|
||
const shopAvv = shopRechtsunterlage('avv-webshop')
|
||
const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
|
||
|
||
const SHOP_PREIS = '299 €'
|
||
const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen'
|
||
|
||
/**
|
||
* Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die
|
||
* Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile
|
||
* (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie
|
||
* termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung
|
||
* neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und
|
||
* die Pflichtinformationen sind Entwurf bis zur Kanzlei.
|
||
*/
|
||
function shopConsent() {
|
||
let versionGesetzt // undefined = config.shop.fassung
|
||
let gebaut = null // { version, items }
|
||
const version = () => versionGesetzt ?? config.shop.fassung
|
||
const items = (v) => [
|
||
{
|
||
id: 'unterlagen',
|
||
haken: true,
|
||
text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`,
|
||
},
|
||
{
|
||
id: 'verantwortung',
|
||
haken: true,
|
||
text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.',
|
||
},
|
||
{
|
||
id: 'stripe',
|
||
haken: true,
|
||
text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.',
|
||
},
|
||
{
|
||
id: 'beginn',
|
||
haken: true,
|
||
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops 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 Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop 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.shop.fassung ? undefined : wert
|
||
},
|
||
knopf: `Zahlungspflichtig bestellen · ${SHOP_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,
|
||
* 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. 5 Nr. 2 BGB,
|
||
// Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind
|
||
// Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (<F>); bestehende
|
||
// Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind.
|
||
consent: {
|
||
get version() {
|
||
return config.termine.avvVersion
|
||
},
|
||
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 mit der vollständigen Erbringung des Einbaus 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).',
|
||
},
|
||
],
|
||
},
|
||
// Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = <F>
|
||
leistungsbeschreibung: {
|
||
get version() {
|
||
return config.termine.avvVersion
|
||
},
|
||
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: `einmalig, ${ustHinweis}; 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: TERMINE_PREIS,
|
||
setupEstimate: TERMINE_DAUER,
|
||
consent: termineConsent(),
|
||
// Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
|
||
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
|
||
get leistungsbeschreibung() {
|
||
return termineLb.get()
|
||
},
|
||
set leistungsbeschreibung(wert) {
|
||
termineLb.set(wert)
|
||
},
|
||
get avv() {
|
||
return termineAvv.get()
|
||
},
|
||
set avv(wert) {
|
||
termineAvv.set(wert)
|
||
},
|
||
get widerruf() {
|
||
return termineWiderruf.get()
|
||
},
|
||
set widerruf(wert) {
|
||
termineWiderruf.set(wert)
|
||
},
|
||
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.',
|
||
},
|
||
shop: {
|
||
key: 'shop',
|
||
product: 'shopSetup',
|
||
label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' },
|
||
priceLabel: SHOP_PREIS,
|
||
setupEstimate: SHOP_DAUER,
|
||
consent: shopConsent(),
|
||
// Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
|
||
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
|
||
get leistungsbeschreibung() {
|
||
return shopLb.get()
|
||
},
|
||
set leistungsbeschreibung(wert) {
|
||
shopLb.set(wert)
|
||
},
|
||
get avv() {
|
||
return shopAvv.get()
|
||
},
|
||
set avv(wert) {
|
||
shopAvv.set(wert)
|
||
},
|
||
get widerruf() {
|
||
return shopWiderruf.get()
|
||
},
|
||
set widerruf(wert) {
|
||
shopWiderruf.set(wert)
|
||
},
|
||
returnHash: 'webshop',
|
||
returnProjekt: true,
|
||
ticket: {
|
||
topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`,
|
||
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
|
||
details: async (ctx) => {
|
||
const { shopTicketDetails } = await import('./services/shop/ticketDetails.js')
|
||
return shopTicketDetails(ctx)
|
||
},
|
||
},
|
||
mails: {
|
||
funktion: 'Webshop',
|
||
formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' },
|
||
knopf: 'Webshop öffnen',
|
||
danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.',
|
||
bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.',
|
||
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 Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
|
||
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
|
||
unavailableMessage: 'Der Webshop 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',
|
||
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs)
|
||
shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
|
||
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
|
||
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',
|
||
},
|
||
|
||
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,
|
||
shopSetup,
|
||
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),
|
||
// Fassung der Terminplaner-Bestellunterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung, Bestelltexte, News-
|
||
// Zustimmung): Berliner Kalendertag des Fassungs-Commits (JJJJ-MM-TT). Eine ausgelieferte Fassung wird nie
|
||
// ueberschrieben: nach Uebergabe an die Kanzlei oder Livegang heisst jede Textaenderung neues Datum und neue Dateien.
|
||
avvVersion: '2026-10-05',
|
||
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),
|
||
},
|
||
|
||
// Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit);
|
||
// Schalter siehe server/services/shop/schalter.js
|
||
shop: {
|
||
// off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden)
|
||
mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off',
|
||
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||
pilotEmails: (process.env.SHOP_PILOT_EMAILS || '')
|
||
.split(',')
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
// Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des
|
||
// Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
|
||
fassung: '2026-10-05',
|
||
// Artikelbilder der Shops: <uploadDir>/<shopId>/<datei>
|
||
uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'),
|
||
// Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503
|
||
connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '',
|
||
// Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange
|
||
bestellAblaufMinuten: 30,
|
||
maxArtikel: 500,
|
||
maxBilder: 4,
|
||
maxVarianten: 30,
|
||
// Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J
|
||
anonymisierenJahre: 3,
|
||
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot)
|
||
formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3),
|
||
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0.
|
||
minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300),
|
||
// Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten
|
||
loeschTageNachAbschalten: 90,
|
||
// Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht
|
||
unbezahltLoeschTage: 30,
|
||
},
|
||
|
||
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.')
|
||
}
|
||
}
|