329 lines
16 KiB
JavaScript
329 lines
16 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
|
||
}
|
||
|
||
// 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 €',
|
||
}
|
||
|
||
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',
|
||
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',
|
||
},
|
||
|
||
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: (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, ''),
|
||
|
||
// 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,
|
||
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
|
||
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
|
||
funktionen: {
|
||
setupEstimate: 'meist innerhalb von 1–2 Wochen',
|
||
consentText:
|
||
'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).',
|
||
},
|
||
|
||
// 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',
|
||
},
|
||
|
||
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 || '',
|
||
},
|
||
|
||
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.')
|
||
}
|
||
}
|