import { createServer } from "node:http"; import { readFile, stat } from "node:fs/promises"; import { chmodSync, mkdirSync } from "node:fs"; import { dirname, extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; import { createHash, randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; import { mailConfigured, sendMail } from "./mailer.mjs"; import { buildBillingOrderLines, buildOrderLines, catalog, createCheckoutSession, publicCatalog, stripeConfigured, verifyStripeSignature } from "./stripe.mjs"; import { activationCodeValid, createActivationCode, normalizeActivationCode, normalizeSystemConfiguration, licenseCoverage, publicWizardCatalog, recommendLicenses } from "./portal-domain.mjs"; import { buildSymconPackage } from "./symcon-package.mjs"; import { activeVatRate } from "./catalog-settings.mjs"; import { ensurePlatformOperationsSchema, platformFaultDeliveries } from "./platform-operations.mjs"; const scrypt = promisify(scryptCallback); const appDir = dirname(fileURLToPath(import.meta.url)); const publicDir = process.env.PUBLIC_DIR || join(appDir, "public"); const dataDir = process.env.DATA_DIR || join(appDir, "data"); const port = Number(process.env.PORT || 8080); const secureCookies = process.env.COOKIE_SECURE !== "false"; const sessionLifetime = 60 * 60 * 24 * 14; const maxBodyBytes = 1024 * 1024; const maxWebhookBytes = 256 * 1024; const sessionCookie = "enelix_session"; const authAttempts = new Map(); const checkoutAttempts = new Map(); const activationAttempts = new Map(); const deviceAttempts = new Map(); const activationRotations = new Set(); const publicBaseUrl = String(process.env.PUBLIC_BASE_URL || "https://license.enelix.ch").replace(/\/$/, ""); const requireEmailVerification = process.env.REQUIRE_EMAIL_VERIFICATION !== "false"; const prognosisApiUrl = String(process.env.PROGNOSIS_API_URL || "").replace(/\/$/, ""); const prognosisServiceToken = String(process.env.PROGNOSIS_SERVICE_TOKEN || ""); process.umask(0o077); mkdirSync(dataDir, { recursive: true }); const databasePath = join(dataDir, "portal.sqlite"); const catalogPath = join(dataDir, "catalog.json"); const db = new DatabaseSync(databasePath); chmodSync(databasePath, 0o600); async function loadCatalogSettings() { let stored = {}; try { stored = JSON.parse(await readFile(catalogPath, "utf8")); } catch (error) { if (error.code !== "ENOENT") console.error("catalog_read_failed", error.message); } const items = Object.fromEntries(Object.entries(catalog).map(([key, base]) => { const override = stored.items?.[key] || {}; const unitAmount = Number(override.unitAmount); const setupAmount = Number(override.setupAmount); return [key, { ...base, unitAmount: Number.isInteger(unitAmount) && unitAmount >= 0 ? unitAmount : base.unitAmount, setupAmount: Number.isInteger(setupAmount) && setupAmount >= 0 ? setupAmount : base.setupAmount, available: override.available !== false }]; })); return { vatRate: activeVatRate(stored), items }; } db.exec(` PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS organizations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner_user_id INTEGER NOT NULL REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS memberships ( user_id INTEGER NOT NULL REFERENCES users(id), organization_id INTEGER NOT NULL REFERENCES organizations(id), role TEXT NOT NULL, PRIMARY KEY (user_id, organization_id) ); CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), csrf_token TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS plants ( id TEXT PRIMARY KEY, organization_id INTEGER REFERENCES organizations(id) ON DELETE CASCADE, owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, installation_id TEXT, location TEXT NOT NULL, manager_variant TEXT NOT NULL, purchase_mode TEXT NOT NULL DEFAULT 'licenses', configuration_id TEXT, status TEXT NOT NULL DEFAULT 'Nicht verknüpft', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK ((organization_id IS NOT NULL AND owner_user_id IS NULL) OR (organization_id IS NULL AND owner_user_id IS NOT NULL)) ); CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at); CREATE INDEX IF NOT EXISTS plants_org_idx ON plants(organization_id); `); const userColumns = new Set(db.prepare("PRAGMA table_info(users)").all().map((column) => column.name)); if (!userColumns.has("email_verified_at")) { db.exec("ALTER TABLE users ADD COLUMN email_verified_at TEXT"); db.exec("UPDATE users SET email_verified_at = CURRENT_TIMESTAMP"); } const sessionColumns = new Set(db.prepare("PRAGMA table_info(sessions)").all().map((column) => column.name)); if (!sessionColumns.has("active_organization_id")) { db.exec("ALTER TABLE sessions ADD COLUMN active_organization_id INTEGER REFERENCES organizations(id)"); } const plantColumns = db.prepare("PRAGMA table_info(plants)").all(); const plantOrganization = plantColumns.find((column) => column.name === "organization_id"); const plantInstallation = plantColumns.find((column) => column.name === "installation_id"); if (!plantColumns.some((column) => column.name === "owner_user_id") || plantOrganization?.notnull || plantInstallation?.notnull || !plantColumns.some((column) => column.name === "purchase_mode") || !plantColumns.some((column) => column.name === "configuration_id")) { db.exec("PRAGMA foreign_keys = OFF"); db.exec("PRAGMA legacy_alter_table = ON"); db.exec("BEGIN IMMEDIATE"); try { db.exec(` ALTER TABLE plants RENAME TO plants_legacy; CREATE TABLE plants ( id TEXT PRIMARY KEY, organization_id INTEGER REFERENCES organizations(id) ON DELETE CASCADE, owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, installation_id TEXT, location TEXT NOT NULL, manager_variant TEXT NOT NULL, purchase_mode TEXT NOT NULL DEFAULT 'licenses', configuration_id TEXT, status TEXT NOT NULL DEFAULT 'Nicht verknüpft', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK ((organization_id IS NOT NULL AND owner_user_id IS NULL) OR (organization_id IS NULL AND owner_user_id IS NOT NULL)) ); INSERT INTO plants (id, organization_id, owner_user_id, name, type, installation_id, location, manager_variant, purchase_mode, status, created_at) SELECT id, organization_id, ${plantColumns.some((column) => column.name === "owner_user_id") ? "owner_user_id" : "NULL"}, name, type, installation_id, location, manager_variant, 'licenses', status, created_at FROM plants_legacy; DROP TABLE plants_legacy; CREATE INDEX plants_org_idx ON plants(organization_id); CREATE INDEX plants_owner_idx ON plants(owner_user_id); CREATE UNIQUE INDEX plants_org_installation_unique ON plants(organization_id, installation_id) WHERE organization_id IS NOT NULL; CREATE UNIQUE INDEX plants_owner_installation_unique ON plants(owner_user_id, installation_id) WHERE owner_user_id IS NOT NULL; `); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } finally { db.exec("PRAGMA legacy_alter_table = OFF"); db.exec("PRAGMA foreign_keys = ON"); } } db.exec(` CREATE TABLE IF NOT EXISTS email_tokens ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS integration_state ( id TEXT PRIMARY KEY, configured INTEGER NOT NULL DEFAULT 0, detail TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS organization_invitations ( token_hash TEXT PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, invited_email TEXT NOT NULL, role TEXT NOT NULL, invited_by_user_id INTEGER NOT NULL REFERENCES users(id), expires_at INTEGER NOT NULL, accepted_at INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS system_configurations ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), organization_id INTEGER REFERENCES organizations(id), plant_id TEXT UNIQUE REFERENCES plants(id) ON DELETE CASCADE, configuration_json TEXT NOT NULL, recommendation_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), organization_id INTEGER REFERENCES organizations(id), plant_id TEXT NOT NULL REFERENCES plants(id), currency TEXT NOT NULL DEFAULT 'chf', amount_total INTEGER NOT NULL, status TEXT NOT NULL, stripe_checkout_session_id TEXT UNIQUE, stripe_payment_intent_id TEXT, paid_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS order_lines ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, sku TEXT NOT NULL, catalog_key TEXT NOT NULL, description TEXT NOT NULL, line_type TEXT NOT NULL, quantity INTEGER NOT NULL, unit_amount INTEGER NOT NULL, term_months INTEGER, entitlement INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL REFERENCES orders(id), provider TEXT NOT NULL, external_reference TEXT NOT NULL UNIQUE, amount INTEGER NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS entitlements ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL REFERENCES orders(id), plant_id TEXT NOT NULL REFERENCES plants(id), sku TEXT NOT NULL, catalog_key TEXT NOT NULL, quantity INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'active', valid_from TEXT, valid_until TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(order_id, sku) ); CREATE TABLE IF NOT EXISTS activation_codes ( order_id TEXT PRIMARY KEY REFERENCES orders(id) ON DELETE CASCADE, plant_id TEXT NOT NULL REFERENCES plants(id), code_hash TEXT NOT NULL UNIQUE, code_hint TEXT NOT NULL, created_by_user_id INTEGER NOT NULL REFERENCES users(id), installation_id TEXT UNIQUE, manager_public_key TEXT, activated_at TEXT, last_seen_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS stripe_events ( event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS fault_monitor_state ( plant_id TEXT PRIMARY KEY REFERENCES plants(id) ON DELETE CASCADE, installation_id TEXT NOT NULL, captured_at TEXT NOT NULL, last_received_at TEXT NOT NULL, manager_instance_id INTEGER NOT NULL, manager_role TEXT NOT NULL, manager_active INTEGER NOT NULL, snapshot_hash TEXT NOT NULL, active_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS fault_occurrences ( id INTEGER PRIMARY KEY AUTOINCREMENT, plant_id TEXT NOT NULL REFERENCES plants(id) ON DELETE CASCADE, installation_id TEXT NOT NULL, source_type TEXT NOT NULL, source_id TEXT NOT NULL, source_name TEXT NOT NULL, code TEXT NOT NULL, severity TEXT NOT NULL, message TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, resolved_at TEXT ); CREATE INDEX IF NOT EXISTS fault_occurrences_plant_idx ON fault_occurrences(plant_id, resolved_at, last_seen_at); CREATE UNIQUE INDEX IF NOT EXISTS fault_occurrences_active_unique ON fault_occurrences(plant_id, source_id, code) WHERE resolved_at IS NULL; CREATE TABLE IF NOT EXISTS fault_notification_recipients ( id INTEGER PRIMARY KEY AUTOINCREMENT, plant_id TEXT NOT NULL REFERENCES plants(id) ON DELETE CASCADE, email TEXT NOT NULL COLLATE NOCASE, created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(plant_id, email) ); CREATE TABLE IF NOT EXISTS fault_notification_outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, plant_id TEXT NOT NULL REFERENCES plants(id) ON DELETE CASCADE, recipient_email TEXT NOT NULL, event_key TEXT NOT NULL, payload_json TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL, last_error TEXT, sent_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(plant_id, recipient_email, event_key) ); CREATE INDEX IF NOT EXISTS fault_notification_recipients_plant_idx ON fault_notification_recipients(plant_id, created_at); CREATE INDEX IF NOT EXISTS fault_notification_outbox_due_idx ON fault_notification_outbox(sent_at, next_attempt_at); CREATE INDEX IF NOT EXISTS email_tokens_user_idx ON email_tokens(user_id, purpose); CREATE INDEX IF NOT EXISTS email_tokens_expiry_idx ON email_tokens(expires_at); CREATE INDEX IF NOT EXISTS invitations_email_idx ON organization_invitations(invited_email, expires_at); CREATE INDEX IF NOT EXISTS orders_user_idx ON orders(user_id, created_at); CREATE INDEX IF NOT EXISTS orders_org_idx ON orders(organization_id, created_at); CREATE INDEX IF NOT EXISTS order_lines_order_idx ON order_lines(order_id); CREATE INDEX IF NOT EXISTS configurations_org_idx ON system_configurations(organization_id, created_at); CREATE INDEX IF NOT EXISTS configurations_user_idx ON system_configurations(user_id, created_at); CREATE INDEX IF NOT EXISTS activation_installation_idx ON activation_codes(installation_id); `); ensurePlatformOperationsSchema(db); const activationColumns = new Set( db.prepare("PRAGMA table_info(activation_codes)").all().map((column) => column.name) ); if (!activationColumns.has("device_token_hash")) { db.exec("ALTER TABLE activation_codes ADD COLUMN device_token_hash TEXT"); } if (!activationColumns.has("device_token_issued_at")) { db.exec("ALTER TABLE activation_codes ADD COLUMN device_token_issued_at TEXT"); } if (!activationColumns.has("topology_last_seen_at")) { db.exec("ALTER TABLE activation_codes ADD COLUMN topology_last_seen_at TEXT"); } if (!activationColumns.has("telemetry_last_seen_at")) { db.exec("ALTER TABLE activation_codes ADD COLUMN telemetry_last_seen_at TEXT"); } db.exec("CREATE UNIQUE INDEX IF NOT EXISTS activation_device_token_idx ON activation_codes(device_token_hash) WHERE device_token_hash IS NOT NULL"); const orderLineColumns = new Set( db.prepare("PRAGMA table_info(order_lines)").all().map((column) => column.name) ); if (!orderLineColumns.has("term_months")) { db.exec("ALTER TABLE order_lines ADD COLUMN term_months INTEGER"); } const entitlementColumns = new Set( db.prepare("PRAGMA table_info(entitlements)").all().map((column) => column.name) ); if (!entitlementColumns.has("valid_from")) { db.exec("ALTER TABLE entitlements ADD COLUMN valid_from TEXT"); } if (!entitlementColumns.has("valid_until")) { db.exec("ALTER TABLE entitlements ADD COLUMN valid_until TEXT"); } db.exec("CREATE INDEX IF NOT EXISTS entitlements_validity_idx ON entitlements(plant_id, catalog_key, status, valid_from, valid_until)"); const statements = { insertUser: db.prepare("INSERT INTO users (email, password_hash, display_name) VALUES (?, ?, ?)"), findUser: db.prepare("SELECT * FROM users WHERE email = ?"), findUserById: db.prepare("SELECT * FROM users WHERE id = ?"), insertOrg: db.prepare("INSERT INTO organizations (name, owner_user_id) VALUES (?, ?)"), insertMembership: db.prepare("INSERT OR IGNORE INTO memberships (user_id, organization_id, role) VALUES (?, ?, ?)"), insertSession: db.prepare("INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at, active_organization_id) VALUES (?, ?, ?, ?, ?)"), firstMembership: db.prepare("SELECT organization_id FROM memberships WHERE user_id = ? ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, organization_id LIMIT 1"), setSessionOrganization: db.prepare("UPDATE sessions SET active_organization_id = ? WHERE token_hash = ? AND user_id = ?"), deleteSession: db.prepare("DELETE FROM sessions WHERE token_hash = ?"), deleteExpired: db.prepare("DELETE FROM sessions WHERE expires_at <= ?"), session: db.prepare(` SELECT u.id AS user_id, u.email, u.display_name, u.email_verified_at, s.active_organization_id AS organization_id, o.name AS organization_name, m.role, s.csrf_token, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN memberships m ON m.user_id = u.id AND m.organization_id = s.active_organization_id LEFT JOIN organizations o ON o.id = m.organization_id WHERE s.token_hash = ? AND s.expires_at > ? LIMIT 1 `), memberships: db.prepare(` SELECT o.id, o.name, m.role FROM memberships m JOIN organizations o ON o.id = m.organization_id WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE `), membership: db.prepare("SELECT role FROM memberships WHERE user_id = ? AND organization_id = ?"), organization: db.prepare("SELECT id, name, owner_user_id FROM organizations WHERE id = ?"), plantsOrganization: db.prepare(` SELECT p.id, p.name, p.type, p.installation_id, p.location, p.manager_variant, p.purchase_mode, p.configuration_id, p.status, p.created_at, c.configuration_json, c.recommendation_json, (SELECT MAX(a.last_seen_at) FROM activation_codes a WHERE a.plant_id = p.id) AS license_last_seen_at FROM plants p LEFT JOIN system_configurations c ON c.id = p.configuration_id WHERE p.organization_id = ? ORDER BY p.created_at DESC `), plantsPersonal: db.prepare(` SELECT p.id, p.name, p.type, p.installation_id, p.location, p.manager_variant, p.purchase_mode, p.configuration_id, p.status, p.created_at, c.configuration_json, c.recommendation_json, (SELECT MAX(a.last_seen_at) FROM activation_codes a WHERE a.plant_id = p.id) AS license_last_seen_at FROM plants p LEFT JOIN system_configurations c ON c.id = p.configuration_id WHERE p.owner_user_id = ? AND p.organization_id IS NULL ORDER BY p.created_at DESC `), plantOrganization: db.prepare("SELECT * FROM plants WHERE id = ? AND organization_id = ?"), plantPersonal: db.prepare("SELECT * FROM plants WHERE id = ? AND owner_user_id = ? AND organization_id IS NULL"), personalPlantsForMove: db.prepare("SELECT id, installation_id FROM plants WHERE owner_user_id = ? AND organization_id IS NULL"), organizationInstallation: db.prepare("SELECT id FROM plants WHERE organization_id = ? AND installation_id = ?"), clearPlantInstallation: db.prepare("UPDATE plants SET installation_id = NULL WHERE id = ?"), releasePlantActivation: db.prepare(` UPDATE plants SET installation_id = NULL, status = CASE WHEN configuration_id IS NULL THEN 'Entwurf' ELSE 'Konfiguriert' END WHERE id = ? `), insertPlant: db.prepare(` INSERT INTO plants (id, organization_id, owner_user_id, name, type, installation_id, location, manager_variant, purchase_mode, configuration_id, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), movePersonalPlants: db.prepare("UPDATE plants SET organization_id = ?, owner_user_id = NULL WHERE owner_user_id = ? AND organization_id IS NULL"), movePersonalOrders: db.prepare("UPDATE orders SET organization_id = ? WHERE user_id = ? AND organization_id IS NULL"), movePersonalSessions: db.prepare("UPDATE sessions SET active_organization_id = ? WHERE user_id = ? AND active_organization_id IS NULL"), markEmailVerified: db.prepare("UPDATE users SET email_verified_at = COALESCE(email_verified_at, CURRENT_TIMESTAMP) WHERE id = ?"), updatePassword: db.prepare("UPDATE users SET password_hash = ? WHERE id = ?"), deleteUserSessions: db.prepare("DELETE FROM sessions WHERE user_id = ?"), deleteOtherUserSessions: db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash != ?"), deleteEmailTokens: db.prepare("DELETE FROM email_tokens WHERE user_id = ? AND purpose = ?"), insertEmailToken: db.prepare("INSERT INTO email_tokens (token_hash, user_id, purpose, expires_at) VALUES (?, ?, ?, ?)"), emailToken: db.prepare(` SELECT t.token_hash, t.user_id, t.purpose, u.email, u.display_name, u.email_verified_at FROM email_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.purpose = ? AND t.consumed_at IS NULL AND t.expires_at > ? `), deleteExpiredEmailTokens: db.prepare("DELETE FROM email_tokens WHERE expires_at <= ? OR consumed_at IS NOT NULL"), setIntegrationState: db.prepare(` INSERT INTO integration_state (id, configured, detail, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET configured = excluded.configured, detail = excluded.detail, updated_at = CURRENT_TIMESTAMP `), deletePendingInvitation: db.prepare("DELETE FROM organization_invitations WHERE organization_id = ? AND invited_email = ? AND accepted_at IS NULL"), insertInvitation: db.prepare(` INSERT INTO organization_invitations (token_hash, organization_id, invited_email, role, invited_by_user_id, expires_at) VALUES (?, ?, ?, ?, ?, ?) `), invitation: db.prepare(` SELECT i.token_hash, i.organization_id, i.invited_email, i.role, i.expires_at, o.name AS organization_name FROM organization_invitations i JOIN organizations o ON o.id = i.organization_id WHERE i.token_hash = ? AND i.accepted_at IS NULL AND i.expires_at > ? `), acceptInvitation: db.prepare("UPDATE organization_invitations SET accepted_at = ? WHERE token_hash = ? AND accepted_at IS NULL"), pendingInvitations: db.prepare(` SELECT invited_email, role, expires_at, created_at FROM organization_invitations WHERE organization_id = ? AND accepted_at IS NULL AND expires_at > ? ORDER BY created_at DESC `), deleteExpiredInvitations: db.prepare("DELETE FROM organization_invitations WHERE expires_at <= ? AND accepted_at IS NULL"), insertConfiguration: db.prepare(` INSERT INTO system_configurations (id, user_id, organization_id, plant_id, configuration_json, recommendation_json) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(plant_id) DO UPDATE SET user_id = excluded.user_id, organization_id = excluded.organization_id, configuration_json = excluded.configuration_json, recommendation_json = excluded.recommendation_json, updated_at = CURRENT_TIMESTAMP `), updateConfiguredPlant: db.prepare(` UPDATE plants SET name = ?, type = ?, location = ?, purchase_mode = 'configured', configuration_id = ?, status = CASE WHEN installation_id IS NULL THEN 'Konfiguriert' ELSE status END WHERE id = ? `), updateCostReportPlant: db.prepare(` UPDATE plants SET name = ?, type = 'Abrechnung', location = ?, purchase_mode = 'configured', status = CASE WHEN installation_id IS NULL THEN 'Konfiguriert' ELSE status END WHERE id = ? AND manager_variant = 'billing_manager' `), configurationByPlant: db.prepare("SELECT * FROM system_configurations WHERE plant_id = ?"), configurationOrganization: db.prepare("SELECT * FROM system_configurations WHERE id = ? AND organization_id = ?"), configurationPersonal: db.prepare("SELECT * FROM system_configurations WHERE id = ? AND user_id = ? AND organization_id IS NULL"), insertOrder: db.prepare(` INSERT INTO orders (id, user_id, organization_id, plant_id, amount_total, status) VALUES (?, ?, ?, ?, ?, 'creating_checkout') `), insertOrderLine: db.prepare(` INSERT INTO order_lines (order_id, sku, catalog_key, description, line_type, quantity, unit_amount, term_months, entitlement) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `), checkoutReady: db.prepare(` UPDATE orders SET stripe_checkout_session_id = ?, status = 'checkout_open', updated_at = CURRENT_TIMESTAMP WHERE id = ? `), checkoutFailed: db.prepare("UPDATE orders SET status = 'checkout_failed', updated_at = CURRENT_TIMESTAMP WHERE id = ?"), markFreeOrderPaid: db.prepare(` UPDATE orders SET status = 'paid', stripe_payment_intent_id = ?, paid_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND amount_total = 0 AND status = 'creating_checkout' `), insertFreePayment: db.prepare(` INSERT INTO payments (order_id, provider, external_reference, amount, currency, status) VALUES (?, 'internal', ?, 0, 'chf', 'paid') `), orderById: db.prepare("SELECT * FROM orders WHERE id = ?"), ordersOrganization: db.prepare(` SELECT o.id, o.plant_id, o.amount_total, o.currency, o.status, o.created_at, o.paid_at, p.name AS plant_name, a.code_hint, a.installation_id AS activated_installation_id, a.activated_at FROM orders o JOIN plants p ON p.id = o.plant_id LEFT JOIN activation_codes a ON a.order_id = o.id WHERE o.organization_id = ? ORDER BY o.created_at DESC LIMIT 100 `), ordersPersonal: db.prepare(` SELECT o.id, o.plant_id, o.amount_total, o.currency, o.status, o.created_at, o.paid_at, p.name AS plant_name, a.code_hint, a.installation_id AS activated_installation_id, a.activated_at FROM orders o JOIN plants p ON p.id = o.plant_id LEFT JOIN activation_codes a ON a.order_id = o.id WHERE o.user_id = ? AND o.organization_id IS NULL ORDER BY o.created_at DESC LIMIT 100 `), orderOrganization: db.prepare("SELECT * FROM orders WHERE id = ? AND organization_id = ?"), orderPersonal: db.prepare("SELECT * FROM orders WHERE id = ? AND user_id = ? AND organization_id IS NULL"), orderLines: db.prepare(` SELECT sku, catalog_key, description, line_type, quantity, unit_amount, term_months, entitlement FROM order_lines WHERE order_id = ? ORDER BY id `), insertStripeEvent: db.prepare("INSERT INTO stripe_events (event_id, event_type) VALUES (?, ?)"), markOrderPaid: db.prepare(` UPDATE orders SET status = 'paid', stripe_payment_intent_id = ?, paid_at = COALESCE(paid_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND stripe_checkout_session_id = ? `), markOrderStatus: db.prepare("UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND stripe_checkout_session_id = ? AND status != 'paid'"), insertPayment: db.prepare(` INSERT OR IGNORE INTO payments (order_id, provider, external_reference, amount, currency, status) VALUES (?, 'stripe', ?, ?, ?, ?) `), createEntitlements: db.prepare(` INSERT OR IGNORE INTO entitlements (order_id, plant_id, sku, catalog_key, quantity, valid_from, valid_until) SELECT l.order_id, o.plant_id, l.sku, l.catalog_key, l.quantity, CASE WHEN l.term_months IS NULL THEN NULL ELSE COALESCE((SELECT MAX(e.valid_until) FROM entitlements e WHERE e.plant_id = o.plant_id AND e.catalog_key = l.catalog_key AND e.status = 'active' AND e.valid_until > CURRENT_TIMESTAMP), CURRENT_TIMESTAMP) END, CASE WHEN l.term_months IS NULL THEN NULL ELSE datetime(COALESCE((SELECT MAX(e.valid_until) FROM entitlements e WHERE e.plant_id = o.plant_id AND e.catalog_key = l.catalog_key AND e.status = 'active' AND e.valid_until > CURRENT_TIMESTAMP), CURRENT_TIMESTAMP), '+' || l.term_months || ' months') END FROM order_lines l JOIN orders o ON o.id = l.order_id WHERE l.order_id = ? AND l.entitlement = 1 `), activationByOrder: db.prepare("SELECT * FROM activation_codes WHERE order_id = ?"), activationByCode: db.prepare(` SELECT a.*, o.status AS order_status, o.organization_id, o.user_id FROM activation_codes a JOIN orders o ON o.id = a.order_id WHERE a.code_hash = ? `), activationByDeviceToken: db.prepare(` SELECT a.*, o.status AS order_status FROM activation_codes a JOIN orders o ON o.id = a.order_id WHERE a.device_token_hash = ? AND a.installation_id = ? `), insertActivation: db.prepare(` INSERT INTO activation_codes (order_id, plant_id, code_hash, code_hint, created_by_user_id) VALUES (?, ?, ?, ?, ?) `), rotateActivation: db.prepare(` UPDATE activation_codes SET code_hash = ?, code_hint = ?, created_by_user_id = ?, created_at = CURRENT_TIMESTAMP, installation_id = NULL, manager_public_key = NULL, device_token_hash = NULL, device_token_issued_at = NULL, topology_last_seen_at = NULL, telemetry_last_seen_at = NULL, activated_at = NULL, last_seen_at = NULL WHERE order_id = ? `), revokeActivation: db.prepare(` UPDATE activation_codes SET code_hash = ?, code_hint = 'Gelöscht', created_by_user_id = ?, created_at = CURRENT_TIMESTAMP WHERE order_id = ? `), paidOrderCountByPlant: db.prepare("SELECT COUNT(*) AS count FROM orders WHERE plant_id = ? AND status = 'paid'"), deleteActivationsByPlant: db.prepare("DELETE FROM activation_codes WHERE plant_id = ?"), deleteEntitlementsByPlant: db.prepare("DELETE FROM entitlements WHERE plant_id = ?"), deletePaymentsByPlant: db.prepare("DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE plant_id = ?)"), deleteOrdersByPlant: db.prepare("DELETE FROM orders WHERE plant_id = ?"), deleteConfigurationsByPlant: db.prepare("DELETE FROM system_configurations WHERE plant_id = ?"), deletePlantById: db.prepare("DELETE FROM plants WHERE id = ?"), bindActivation: db.prepare(` UPDATE activation_codes SET installation_id = ?, manager_public_key = ?, activated_at = COALESCE(activated_at, CURRENT_TIMESTAMP), last_seen_at = CURRENT_TIMESTAMP WHERE order_id = ? AND (installation_id IS NULL OR installation_id = ?) `), setDeviceToken: db.prepare(` UPDATE activation_codes SET device_token_hash = ?, device_token_issued_at = CURRENT_TIMESTAMP WHERE order_id = ? AND installation_id = ? `), touchTopology: db.prepare(` UPDATE activation_codes SET topology_last_seen_at = CURRENT_TIMESTAMP WHERE order_id = ? AND installation_id = ? `), touchTelemetry: db.prepare(` UPDATE activation_codes SET telemetry_last_seen_at = CURRENT_TIMESTAMP WHERE order_id = ? AND installation_id = ? `), activatePlant: db.prepare("UPDATE plants SET installation_id = ?, status = 'Aktiv' WHERE id = ?"), updatePlantManager: db.prepare("UPDATE plants SET manager_variant = ? WHERE id = ?"), entitlementByOrder: db.prepare("SELECT sku, catalog_key, quantity, status, valid_from, valid_until FROM entitlements WHERE order_id = ? ORDER BY id"), activeEntitlementsByPlant: db.prepare(` SELECT MIN(sku) AS sku, catalog_key, SUM(quantity) AS quantity, 'active' AS status FROM entitlements WHERE plant_id = ? AND status = 'active' AND (valid_from IS NULL OR valid_from <= CURRENT_TIMESTAMP) AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP) GROUP BY catalog_key `), licenseOverridesByPlant: db.prepare(` SELECT catalog_key, quantity FROM platform_license_overrides WHERE plant_id = ? AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP) `), paidSetupByPlant: db.prepare(` SELECT l.catalog_key, SUM(l.quantity) AS quantity FROM order_lines l JOIN orders o ON o.id = l.order_id WHERE o.plant_id = ? AND o.status = 'paid' AND l.line_type = 'setup' GROUP BY l.catalog_key `), faultStateByPlant: db.prepare("SELECT * FROM fault_monitor_state WHERE plant_id = ?"), activeFaultsByPlant: db.prepare(` SELECT source_type, source_id, source_name, code, severity, message, first_seen_at, last_seen_at FROM fault_occurrences WHERE plant_id = ? AND resolved_at IS NULL ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END, source_name COLLATE NOCASE, code `), resolvedFaultsByPlant: db.prepare(` SELECT source_type, source_id, source_name, code, severity, message, first_seen_at, last_seen_at, resolved_at FROM fault_occurrences WHERE plant_id = ? AND resolved_at IS NOT NULL ORDER BY resolved_at DESC LIMIT 100 `), faultRowsForUpdate: db.prepare(` SELECT id, source_type, source_id, source_name, code, severity, message, first_seen_at, last_seen_at FROM fault_occurrences WHERE plant_id = ? AND resolved_at IS NULL `), insertFault: db.prepare(` INSERT INTO fault_occurrences ( plant_id, installation_id, source_type, source_id, source_name, code, severity, message, first_seen_at, last_seen_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), updateFault: db.prepare(` UPDATE fault_occurrences SET installation_id = ?, source_type = ?, source_name = ?, severity = ?, message = ?, last_seen_at = ? WHERE id = ? AND resolved_at IS NULL `), resolveFault: db.prepare(` UPDATE fault_occurrences SET last_seen_at = ?, resolved_at = ? WHERE id = ? AND resolved_at IS NULL `), upsertFaultState: db.prepare(` INSERT INTO fault_monitor_state ( plant_id, installation_id, captured_at, last_received_at, manager_instance_id, manager_role, manager_active, snapshot_hash, active_count ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(plant_id) DO UPDATE SET installation_id = excluded.installation_id, captured_at = excluded.captured_at, last_received_at = excluded.last_received_at, manager_instance_id = excluded.manager_instance_id, manager_role = excluded.manager_role, manager_active = excluded.manager_active, snapshot_hash = excluded.snapshot_hash, active_count = excluded.active_count `), faultPlant: db.prepare("SELECT id, name FROM plants WHERE id = ?"), faultNotificationRecipients: db.prepare(` SELECT email, created_at FROM fault_notification_recipients WHERE plant_id = ? ORDER BY email COLLATE NOCASE `), faultNotificationRecipientCount: db.prepare(` SELECT COUNT(*) AS count FROM fault_notification_recipients WHERE plant_id = ? `), insertFaultNotificationRecipient: db.prepare(` INSERT INTO fault_notification_recipients (plant_id, email, created_by_user_id) VALUES (?, ?, ?) `), deleteFaultNotificationRecipient: db.prepare(` DELETE FROM fault_notification_recipients WHERE plant_id = ? AND email = ? `), deletePendingFaultNotifications: db.prepare(` DELETE FROM fault_notification_outbox WHERE plant_id = ? AND recipient_email = ? AND sent_at IS NULL `), insertFaultNotification: db.prepare(` INSERT OR IGNORE INTO fault_notification_outbox ( plant_id, recipient_email, event_key, payload_json, next_attempt_at ) VALUES (?, ?, ?, ?, ?) `), dueFaultNotifications: db.prepare(` SELECT id, recipient_email, payload_json, attempts FROM fault_notification_outbox WHERE sent_at IS NULL AND next_attempt_at <= ? ORDER BY id LIMIT 10 `), markFaultNotificationSent: db.prepare(` UPDATE fault_notification_outbox SET sent_at = CURRENT_TIMESTAMP, last_error = NULL WHERE id = ? AND sent_at IS NULL `), retryFaultNotification: db.prepare(` UPDATE fault_notification_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ? AND sent_at IS NULL `) }; function movePersonalWorkspace(userId, organizationId) { for (const plant of statements.personalPlantsForMove.all(userId)) { if (plant.installation_id && statements.organizationInstallation.get(organizationId, plant.installation_id)) { statements.clearPlantInstallation.run(plant.id); } } statements.movePersonalPlants.run(organizationId, userId); statements.movePersonalOrders.run(organizationId, userId); statements.movePersonalSessions.run(organizationId, userId); } function json(res, status, body, extraHeaders = {}) { const payload = JSON.stringify(body); res.writeHead(status, { "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", "Permissions-Policy": "geolocation=(), microphone=(), camera=()", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(payload), "Cache-Control": "no-store", ...extraHeaders }); res.end(payload); } function binary(res, status, content, contentType, filename) { res.writeHead(status, { "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "Content-Type": contentType, "Content-Disposition": `attachment; filename="${filename.replace(/[^A-Za-z0-9._-]/g, "-")}"`, "Content-Length": content.length, "Cache-Control": "no-store" }); res.end(content); } function parseCookies(header = "") { return Object.fromEntries(header.split(";").map((part) => part.trim().split("=")).filter((item) => item.length === 2)); } function tokenHash(token) { return createHash("sha256").update(token).digest("hex"); } async function passwordHash(password) { const salt = randomBytes(16); const derived = await scrypt(password, salt, 64); return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`; } async function passwordMatches(password, stored) { const [algorithm, saltValue, hashValue] = stored.split("$"); if (algorithm !== "scrypt" || !saltValue || !hashValue) return false; const expected = Buffer.from(hashValue, "base64url"); const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length); return timingSafeEqual(expected, actual); } async function bodyBuffer(req, limit = maxBodyBytes) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > limit) throw Object.assign(new Error("too_large"), { status: 413 }); chunks.push(chunk); } return Buffer.concat(chunks); } async function bodyJson(req) { const buffer = await bodyBuffer(req); try { return JSON.parse(buffer.toString("utf8") || "{}"); } catch { throw Object.assign(new Error("invalid_json"), { status: 400 }); } } function clientIp(req) { return String(req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown"); } function checkAuthRate(req) { const ip = clientIp(req); const now = Date.now(); const current = authAttempts.get(ip) || { count: 0, reset: now + 15 * 60 * 1000 }; if (current.reset < now) Object.assign(current, { count: 0, reset: now + 15 * 60 * 1000 }); current.count += 1; authAttempts.set(ip, current); return current.count <= 12; } function checkCheckoutRate(userId) { const now = Date.now(); const key = String(userId); const current = checkoutAttempts.get(key) || { count: 0, reset: now + 60 * 60 * 1000 }; if (current.reset < now) Object.assign(current, { count: 0, reset: now + 60 * 60 * 1000 }); current.count += 1; checkoutAttempts.set(key, current); return current.count <= 10; } function checkActivationRate(req) { const now = Date.now(); const key = clientIp(req); const current = activationAttempts.get(key) || { count: 0, reset: now + 60 * 60 * 1000 }; if (current.reset < now) Object.assign(current, { count: 0, reset: now + 60 * 60 * 1000 }); current.count += 1; activationAttempts.set(key, current); return current.count <= 30; } function checkDeviceRate(req, installationId, scope = "legacy") { // Fixed internal scope, never a caller-selected URL/query value. Keep the // existing V1 budget; V4 observations/read/ack share one additional bounded pool. if (scope !== "legacy" && scope !== "planner-v4") return false; const now = Date.now(); const legacyKey = `${clientIp(req)}:${installationId}`; const key = scope === "legacy" ? legacyKey : `${legacyKey}:planner-v4`; const current = deviceAttempts.get(key) || { count: 0, reset: now + 60 * 60 * 1000 }; if (current.reset <= now) Object.assign(current, { count: 0, reset: now + 60 * 60 * 1000 }); current.count += 1; if (scope === "planner-v4") { if (!Number.isFinite(current.burstReset) || current.burstReset <= now) { current.burstCount = 0; current.burstReset = now + 60 * 1000; } current.burstCount += 1; } deviceAttempts.set(key, current); return current.count <= (scope === "legacy" ? 120 : 360) && (scope === "legacy" || current.burstCount <= 12); } function roleAllowed(session, roles) { return !session.organization_id || roles.includes(session.role); } function getSession(req) { const token = parseCookies(req.headers.cookie)[sessionCookie]; if (!token || token.length > 128) return null; const hash = tokenHash(token); let session = statements.session.get(hash, Math.floor(Date.now() / 1000)) || null; if (session?.organization_id && !session.organization_name) { session.organization_id = null; session.role = null; } if (session && !session.organization_id) { const membership = statements.firstMembership.get(session.user_id); if (membership) { statements.setSessionOrganization.run(membership.organization_id, hash, session.user_id); session = statements.session.get(hash, Math.floor(Date.now() / 1000)) || null; } } return session; } function requireSession(req, res, csrf = false) { const session = getSession(req); if (!session) { json(res, 401, { error: "Bitte zuerst anmelden." }); return null; } if (csrf && req.headers["x-csrf-token"] !== session.csrf_token) { json(res, 403, { error: "Die Sicherheitsprüfung ist abgelaufen. Bitte neu laden." }); return null; } return session; } function createSession(userId) { const token = randomBytes(32).toString("base64url"); const csrf = randomBytes(24).toString("base64url"); const expires = Math.floor(Date.now() / 1000) + sessionLifetime; const organizationId = statements.firstMembership.get(userId)?.organization_id || null; statements.insertSession.run(tokenHash(token), userId, csrf, expires, organizationId); return { token, csrf, expires }; } function sessionCookieHeader(token, maxAge = sessionLifetime) { const secure = secureCookies ? "; Secure" : ""; return `${sessionCookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; } function cleanText(value, maxLength) { return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength); } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[character]); } function issueEmailToken(userId, purpose, lifetimeSeconds) { const token = randomBytes(32).toString("base64url"); statements.insertEmailToken.run(tokenHash(token), userId, purpose, Math.floor(Date.now() / 1000) + lifetimeSeconds); return token; } async function sendVerificationEmail(user) { const token = issueEmailToken(user.id, "verify_email", 24 * 60 * 60); const link = `${publicBaseUrl}/#verify=${encodeURIComponent(token)}`; const name = escapeHtml(user.display_name); await sendMail({ to: user.email, subject: "E-Mail-Adresse für das Enelix EMS Portal bestätigen", text: `Guten Tag ${user.display_name},\n\nbitte bestätigen Sie Ihre E-Mail-Adresse für das Enelix EMS Portal:\n${link}\n\nDer Link ist 24 Stunden und nur einmal gültig.`, html: `

Guten Tag ${name},

bitte bestätigen Sie Ihre E-Mail-Adresse für das Enelix EMS Portal.

E-Mail-Adresse bestätigen

Der Link ist 24 Stunden und nur einmal gültig.

` }); } async function sendPasswordResetEmail(user) { const token = issueEmailToken(user.id, "reset_password", 30 * 60); const link = `${publicBaseUrl}/#reset=${encodeURIComponent(token)}`; const name = escapeHtml(user.display_name); await sendMail({ to: user.email, subject: "Passwort für das Enelix EMS Portal zurücksetzen", text: `Guten Tag ${user.display_name},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen:\n${link}\n\nDer Link ist 30 Minuten und nur einmal gültig. Falls Sie ihn nicht angefordert haben, ignorieren Sie diese Nachricht.`, html: `

Guten Tag ${name},

über diesen Link können Sie Ihr Passwort zurücksetzen.

Neues Passwort festlegen

Der Link ist 30 Minuten und nur einmal gültig. Falls Sie ihn nicht angefordert haben, ignorieren Sie diese Nachricht.

` }); } async function sendOrganizationInvitation(invitation, token) { const link = `${publicBaseUrl}/#invite=${encodeURIComponent(token)}`; const organization = escapeHtml(invitation.organizationName); await sendMail({ to: invitation.email, subject: `Einladung zu ${invitation.organizationName} im Enelix EMS Portal`, text: `Guten Tag,\n\nSie wurden zur Organisation ${invitation.organizationName} im Enelix EMS Portal eingeladen.\n${link}\n\nDer Link ist sieben Tage und nur einmal gültig. Sie können ein bestehendes Konto verwenden oder zuerst ein persönliches Konto erstellen.`, html: `

Guten Tag,

Sie wurden zur Organisation ${organization} im Enelix EMS Portal eingeladen.

Einladung annehmen

Der Link ist sieben Tage und nur einmal gültig. Sie können ein bestehendes Konto verwenden oder zuerst ein persönliches Konto erstellen.

` }); } function publicSession(session) { const organizations = statements.memberships.all(session.user_id); const active = organizations.length && session.organization_id && session.organization_name ? { id: session.organization_id, name: session.organization_name, role: session.role, type: "organization" } : { id: null, name: "Persönlicher Bereich", role: "owner", type: "personal" }; return { authenticated: true, csrfToken: session.csrf_token, user: { id: session.user_id, email: session.email, name: session.display_name, emailVerified: Boolean(session.email_verified_at) }, organization: active, organizations }; } async function register(req, res) { if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const email = cleanText(body.email, 254).toLowerCase(); const name = cleanText(body.name, 80); const password = String(body.password || ""); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json(res, 400, { error: "Bitte eine gültige E-Mail-Adresse eingeben." }); if (name.length < 2) return json(res, 400, { error: "Der Name ist erforderlich." }); if (password.length < 10 || password.length > 200) return json(res, 400, { error: "Das Passwort muss mindestens 10 Zeichen lang sein." }); if (statements.findUser.get(email)) return json(res, 409, { error: "Für diese E-Mail-Adresse besteht bereits ein Konto." }); const hash = await passwordHash(password); let userId; db.exec("BEGIN IMMEDIATE"); try { userId = Number(statements.insertUser.run(email, hash, name).lastInsertRowid); if (!requireEmailVerification) statements.markEmailVerified.run(userId); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); if (String(error.message).includes("UNIQUE")) return json(res, 409, { error: "Für diese E-Mail-Adresse besteht bereits ein Konto." }); throw error; } if (requireEmailVerification) { const user = statements.findUser.get(email); let delivered = false; try { await sendVerificationEmail(user); delivered = true; } catch (error) { console.error("verification_email_failed", error.message); } return json(res, 201, { pendingVerification: true, delivered, message: delivered ? "Konto erstellt. Bitte bestätigen Sie Ihre E-Mail-Adresse über den zugesandten Link." : "Konto erstellt. Die Bestätigungs-E-Mail konnte noch nicht versendet werden. Bitte fordern Sie sie später erneut an." }); } const created = createSession(userId); const session = statements.session.get(tokenHash(created.token), Math.floor(Date.now() / 1000)); return json(res, 201, publicSession(session), { "Set-Cookie": sessionCookieHeader(created.token) }); } async function login(req, res) { if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const email = cleanText(body.email, 254).toLowerCase(); const password = String(body.password || ""); const user = statements.findUser.get(email); if (!user || !(await passwordMatches(password, user.password_hash))) { return json(res, 401, { error: "E-Mail-Adresse oder Passwort ist nicht korrekt." }); } if (requireEmailVerification && !user.email_verified_at) { return json(res, 403, { error: "Bitte zuerst die E-Mail-Adresse bestätigen." }); } const created = createSession(user.id); const session = statements.session.get(tokenHash(created.token), Math.floor(Date.now() / 1000)); json(res, 200, publicSession(session), { "Set-Cookie": sessionCookieHeader(created.token) }); } async function resendVerification(req, res) { if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const email = cleanText(body.email, 254).toLowerCase(); const user = statements.findUser.get(email); if (user && !user.email_verified_at) { try { await sendVerificationEmail(user); } catch (error) { console.error("verification_email_failed", error.message); } } return json(res, 202, { message: "Falls das Konto noch unbestätigt ist, wurde eine neue E-Mail versendet." }); } async function verifyEmail(req, res) { const body = await bodyJson(req); const token = String(body.token || ""); if (!/^[A-Za-z0-9_-]{40,80}$/.test(token)) return json(res, 400, { error: "Der Bestätigungslink ist ungültig oder abgelaufen." }); const now = Math.floor(Date.now() / 1000); db.exec("BEGIN IMMEDIATE"); try { const record = statements.emailToken.get(tokenHash(token), "verify_email", now); if (!record) { db.exec("ROLLBACK"); return json(res, 400, { error: "Der Bestätigungslink ist ungültig oder abgelaufen." }); } statements.markEmailVerified.run(record.user_id); statements.deleteEmailTokens.run(record.user_id, "verify_email"); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 200, { message: "E-Mail-Adresse bestätigt. Sie können sich jetzt anmelden." }); } async function forgotPassword(req, res) { if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const email = cleanText(body.email, 254).toLowerCase(); const user = statements.findUser.get(email); if (user && user.email_verified_at) { try { await sendPasswordResetEmail(user); } catch (error) { console.error("password_reset_email_failed", error.message); } } return json(res, 202, { message: "Falls ein bestätigtes Konto besteht, wurde eine E-Mail versendet." }); } async function resetPassword(req, res) { if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const token = String(body.token || ""); const password = String(body.password || ""); if (!/^[A-Za-z0-9_-]{40,80}$/.test(token)) return json(res, 400, { error: "Der Wiederherstellungslink ist ungültig oder abgelaufen." }); if (password.length < 10 || password.length > 200) return json(res, 400, { error: "Das Passwort muss mindestens 10 Zeichen lang sein." }); const hash = await passwordHash(password); const now = Math.floor(Date.now() / 1000); db.exec("BEGIN IMMEDIATE"); try { const record = statements.emailToken.get(tokenHash(token), "reset_password", now); if (!record) { db.exec("ROLLBACK"); return json(res, 400, { error: "Der Wiederherstellungslink ist ungültig oder abgelaufen." }); } statements.updatePassword.run(hash, record.user_id); statements.deleteUserSessions.run(record.user_id); statements.deleteEmailTokens.run(record.user_id, "reset_password"); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 200, { message: "Das Passwort wurde geändert. Sie können sich jetzt anmelden." }); } async function createOrganization(req, res) { const session = requireSession(req, res, true); if (!session) return; const body = await bodyJson(req); const name = cleanText(body.name, 120); if (name.length < 2) return json(res, 400, { error: "Bitte einen Organisationsnamen eingeben." }); let organizationId; db.exec("BEGIN IMMEDIATE"); try { organizationId = Number(statements.insertOrg.run(name, session.user_id).lastInsertRowid); statements.insertMembership.run(session.user_id, organizationId, "owner"); movePersonalWorkspace(session.user_id, organizationId); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } const token = parseCookies(req.headers.cookie)[sessionCookie]; statements.setSessionOrganization.run(organizationId, tokenHash(token), session.user_id); const updated = statements.session.get(tokenHash(token), Math.floor(Date.now() / 1000)); return json(res, 200, publicSession(updated)); } async function inviteToOrganization(req, res) { const session = requireSession(req, res, true); if (!session) return; if (!session.organization_id || !["owner", "admin"].includes(session.role)) { return json(res, 403, { error: "Nur Eigentümer und Administratoren dürfen einladen." }); } const body = await bodyJson(req); const email = cleanText(body.email, 254).toLowerCase(); const role = cleanText(body.role, 20) || "operator"; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json(res, 400, { error: "Bitte eine gültige E-Mail-Adresse eingeben." }); if (!["admin", "operator", "billing", "viewer"].includes(role)) return json(res, 400, { error: "Ungültige Organisationsrolle." }); const organization = statements.organization.get(session.organization_id); const token = randomBytes(32).toString("base64url"); const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; statements.deletePendingInvitation.run(session.organization_id, email); statements.insertInvitation.run(tokenHash(token), session.organization_id, email, role, session.user_id, expiresAt); try { await sendOrganizationInvitation({ email, organizationName: organization.name }, token); } catch (error) { console.error("organization_invitation_email_failed", error.message); return json(res, 503, { error: "Die Einladung wurde gespeichert, konnte aber nicht versendet werden." }); } return json(res, 201, { message: "Einladung wurde versendet.", invitation: { email, role, expiresAt } }); } async function invitationPreview(req, res) { const body = await bodyJson(req); const token = String(body.token || ""); if (!/^[A-Za-z0-9_-]{40,80}$/.test(token)) return json(res, 400, { error: "Die Einladung ist ungültig oder abgelaufen." }); const invitation = statements.invitation.get(tokenHash(token), Math.floor(Date.now() / 1000)); if (!invitation) return json(res, 400, { error: "Die Einladung ist ungültig oder abgelaufen." }); return json(res, 200, { organization: invitation.organization_name, email: invitation.invited_email, role: invitation.role, expiresAt: invitation.expires_at }); } async function acceptInvitation(req, res) { const session = requireSession(req, res, true); if (!session) return; const body = await bodyJson(req); const token = String(body.token || ""); if (!/^[A-Za-z0-9_-]{40,80}$/.test(token)) return json(res, 400, { error: "Die Einladung ist ungültig oder abgelaufen." }); const hash = tokenHash(token); const now = Math.floor(Date.now() / 1000); const sessionToken = parseCookies(req.headers.cookie)[sessionCookie]; db.exec("BEGIN IMMEDIATE"); try { const invitation = statements.invitation.get(hash, now); if (!invitation || invitation.invited_email !== session.email) { db.exec("ROLLBACK"); return json(res, 400, { error: "Die Einladung ist ungültig, abgelaufen oder für eine andere E-Mail-Adresse bestimmt." }); } statements.insertMembership.run(session.user_id, invitation.organization_id, invitation.role); statements.acceptInvitation.run(new Date().toISOString(), hash); statements.setSessionOrganization.run(invitation.organization_id, tokenHash(sessionToken), session.user_id); db.exec("COMMIT"); const updated = statements.session.get(tokenHash(sessionToken), now); return json(res, 200, { session: publicSession(updated), message: `Einladung zu ${invitation.organization_name} angenommen.` }); } catch (error) { db.exec("ROLLBACK"); throw error; } } function listInvitations(req, res) { const session = requireSession(req, res); if (!session) return; if (!session.organization_id || !["owner", "admin"].includes(session.role)) { return json(res, 403, { error: "Keine Berechtigung für Einladungen." }); } statements.deleteExpiredInvitations.run(Math.floor(Date.now() / 1000)); const invitations = statements.pendingInvitations.all(session.organization_id, Math.floor(Date.now() / 1000)); return json(res, 200, { invitations }); } async function switchWorkspace(req, res) { const session = requireSession(req, res, true); if (!session) return; const body = await bodyJson(req); const organizationId = Number(body.organizationId); const membership = statements.membership.get(session.user_id, organizationId); if (!membership) return json(res, 403, { error: "Kein Zugriff auf diese Organisation." }); const token = parseCookies(req.headers.cookie)[sessionCookie]; statements.setSessionOrganization.run(organizationId, tokenHash(token), session.user_id); const updated = statements.session.get(tokenHash(token), Math.floor(Date.now() / 1000)); return json(res, 200, publicSession(updated)); } function logout(req, res) { const session = requireSession(req, res, true); if (!session) return; const token = parseCookies(req.headers.cookie)[sessionCookie]; statements.deleteSession.run(tokenHash(token)); return json(res, 200, { ok: true }, { "Set-Cookie": sessionCookieHeader("", 0) }); } async function changePassword(req, res) { const session = requireSession(req, res, true); if (!session) return; if (!checkAuthRate(req)) return json(res, 429, { error: "Zu viele Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const currentPassword = String(body.currentPassword || ""); const newPassword = String(body.newPassword || ""); if (newPassword.length < 10 || newPassword.length > 200) { return json(res, 400, { error: "Das neue Passwort muss mindestens 10 Zeichen lang sein." }); } const user = statements.findUserById.get(session.user_id); if (!user || !(await passwordMatches(currentPassword, user.password_hash))) { return json(res, 403, { error: "Das aktuelle Passwort ist nicht korrekt." }); } if (await passwordMatches(newPassword, user.password_hash)) { return json(res, 400, { error: "Das neue Passwort muss sich vom aktuellen Passwort unterscheiden." }); } const hash = await passwordHash(newPassword); const currentToken = parseCookies(req.headers.cookie)[sessionCookie]; db.exec("BEGIN IMMEDIATE"); try { statements.updatePassword.run(hash, session.user_id); statements.deleteOtherUserSessions.run(session.user_id, tokenHash(currentToken)); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 200, { message: "Passwort geändert. Andere Anmeldungen wurden beendet." }); } function listPlants(req, res) { const session = requireSession(req, res); if (!session) return; const plants = session.organization_id ? statements.plantsOrganization.all(session.organization_id) : statements.plantsPersonal.all(session.user_id); json(res, 200, { plants }); } function scopedPlant(session, plantId) { return session.organization_id ? statements.plantOrganization.get(plantId, session.organization_id) : statements.plantPersonal.get(plantId, session.user_id); } function ownedLicenseState(plantId) { const quantities = {}; for (const row of statements.activeEntitlementsByPlant.all(plantId)) { quantities[row.catalog_key] = Number(row.quantity || 0); } for (const row of statements.licenseOverridesByPlant.all(plantId)) { quantities[row.catalog_key] = Number(row.quantity || 0); } const managerVariant = quantities.manager_peak > 0 ? "manager_peak" : (quantities.manager_standard > 0 ? "manager_standard" : null); return { managerVariant, quantities }; } function forecastCapabilities(license) { const schedule = license.managerVariant === "manager_peak" && Number(license.quantities.grid_schedule || 0) > 0; return { pv: schedule || Number(license.quantities.forecast_pv || 0) > 0, load: schedule || Number(license.quantities.forecast_load || 0) > 0, schedule }; } function applyLicensedForecastSelection(configuration, capabilities) { const groups = { pv: ["prog_var_1", "prog_var_10", "prog_var_21"], load: ["prog_var_2", "prog_var_11", "prog_var_22"], schedule: ["prog_var_3", "prog_var_13", "prog_var_23"] }; for (const [capability, keys] of Object.entries(groups)) { if (!capabilities[capability] && keys.some((key) => configuration[key] === true)) { throw Object.assign(new Error("forecast_license_required"), { status: 403, publicMessage: "Die gewählte Prognosefunktion ist nicht lizenziert." }); } } if (groups.schedule.some((key) => configuration[key] === true)) { for (const key of [...groups.pv, ...groups.load]) configuration[key] = true; } return configuration; } function customerForecastSeries(series, capabilities) { const allowed = new Set(["PV", "Hausverbrauch", "Netzleistung", "SOC"]); if (capabilities.pv) for (const key of ["prog_var_1", "prog_var_10", "prog_var_21"]) allowed.add(key); if (capabilities.load) for (const key of ["prog_var_2", "prog_var_11", "prog_var_22"]) allowed.add(key); if (capabilities.schedule) for (const key of ["prog_var_3", "prog_var_13", "prog_var_23"]) allowed.add(key); return (series || []).filter((entry) => allowed.has(entry.key)); } async function readPlantPrognosis(req, res, plantId, url) { const session = requireSession(req, res); if (!session) return; const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "System nicht gefunden." }); const license = ownedLicenseState(plant.id); const capabilities = forecastCapabilities(license); if (!license.managerVariant || !Object.values(capabilities).some(Boolean)) { return json(res, 200, { status: "license_required", managerVariant: license.managerVariant, forecastCapabilities: capabilities, series: [] }); } if (!plant.installation_id) { return json(res, 200, { status: "not_connected", managerVariant: license.managerVariant, forecastCapabilities: capabilities, series: [] }); } if (!prognosisApiUrl || !prognosisServiceToken) { return json(res, 503, { error: "Die Prognoseanbindung ist noch nicht konfiguriert." }); } const historyDays = Number(url.searchParams.get("historyDays") || 14); if (!Number.isInteger(historyDays) || historyDays < 1 || historyDays > 180) { return json(res, 400, { error: "Historie muss zwischen 1 und 180 Tagen liegen." }); } try { const upstream = await fetch( `${prognosisApiUrl}/internal/v1/prognosis/${encodeURIComponent(plant.installation_id)}?history_days=${historyDays}`, { headers: { "X-Enelix-Service-Token": prognosisServiceToken }, signal: AbortSignal.timeout(6000) } ); if (!upstream.ok) { console.error("prognosis_upstream_failed", upstream.status); return json(res, 502, { error: "Die Prognosedaten sind momentan nicht erreichbar." }); } const result = await upstream.json(); if (result.schemaVersion !== 1 || !Array.isArray(result.series)) { console.error("prognosis_upstream_invalid"); return json(res, 502, { error: "Die Prognosedaten haben ein ungültiges Format." }); } return json(res, 200, { ...result, series: customerForecastSeries(result.series, capabilities), forecastCapabilities: capabilities, managerVariant: license.managerVariant, plantId: plant.id, plantName: plant.name }); } catch (error) { console.error("prognosis_upstream_unavailable", error.name); return json(res, 503, { error: "Die Prognosedaten sind momentan nicht erreichbar." }); } } async function prognosisConfigurationRequest(plant, method, body = null) { if (!prognosisApiUrl || !prognosisServiceToken) { throw Object.assign(new Error("prognosis_not_configured"), { status: 503, publicMessage: "Die Prognoseanbindung ist noch nicht konfiguriert." }); } const options = { method, headers: { "X-Enelix-Service-Token": prognosisServiceToken }, signal: AbortSignal.timeout(6000) }; if (body) { options.headers["Content-Type"] = "application/json"; options.body = JSON.stringify(body); } const upstream = await fetch( `${prognosisApiUrl}/internal/v1/prognosis/${encodeURIComponent(plant.installation_id)}/configuration`, options ); const result = await upstream.json().catch(() => ({})); if (!upstream.ok) { console.error("prognosis_configuration_failed", upstream.status); throw Object.assign(new Error("prognosis_configuration_failed"), { status: upstream.status >= 400 && upstream.status < 500 ? 400 : 503, publicMessage: upstream.status >= 400 && upstream.status < 500 ? String(result.detail || "Die Prognosekonfiguration enthält ungültige Werte.") : "Die Prognosekonfiguration ist momentan nicht erreichbar." }); } return result; } function deviceActivation(req, res, installationId) { const authorization = String(req.headers.authorization || ""); const match = /^Bearer ([A-Za-z0-9_-]{43,128})$/.exec(authorization); if (!match) { json(res, 401, { error: "Gerätezugang fehlt oder ist ungültig." }); return null; } const activation = statements.activationByDeviceToken.get( tokenHash(match[1]), installationId ); if (!activation || activation.order_status !== "paid") { json(res, 401, { error: "Gerätezugang ist ungültig oder wurde widerrufen." }); return null; } if (!ownedLicenseState(activation.plant_id).managerVariant) { json(res, 403, { error: "Für diese Installation ist keine aktive Managerlizenz vorhanden." }); return null; } return activation; } async function prognosisTopologyRequest(installationId, topology) { if (!prognosisApiUrl || !prognosisServiceToken) { throw Object.assign(new Error("prognosis_not_configured"), { status: 503, publicMessage: "Die Prognoseanbindung ist noch nicht konfiguriert." }); } const upstream = await fetch( `${prognosisApiUrl}/internal/v1/prognosis/${encodeURIComponent(installationId)}/topology`, { method: "PUT", headers: { "Content-Type": "application/json", "X-Enelix-Service-Token": prognosisServiceToken }, body: JSON.stringify(topology), signal: AbortSignal.timeout(6000) } ); const result = await upstream.json().catch(() => ({})); if (!upstream.ok) { console.error("prognosis_topology_failed", upstream.status); throw Object.assign(new Error("prognosis_topology_failed"), { status: upstream.status >= 400 && upstream.status < 500 ? 400 : 503, publicMessage: upstream.status >= 400 && upstream.status < 500 ? "Die Anlagentopologie enthält ungültige Werte." : "Die Anlagentopologie konnte momentan nicht übertragen werden." }); } return result; } async function uploadManagerTopology(req, res, installationId) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(installationId)) { return json(res, 400, { error: "Die Installations-ID muss eine UUIDv4 sein." }); } if (!checkDeviceRate(req, installationId)) { return json(res, 429, { error: "Zu viele Geräteanfragen. Bitte später erneut versuchen." }); } const activation = deviceActivation(req, res, installationId); if (!activation) return; const topology = await bodyJson(req); if (topology.version !== "1.0" || topology.installationId !== installationId) { return json(res, 400, { error: "Topologieversion oder Installations-ID ist ungültig." }); } const result = await prognosisTopologyRequest(installationId, topology); statements.touchTopology.run(activation.order_id, installationId); return json(res, 200, { status: result.status || "synchronized", technicalSource: result.technicalSource || "manager", installationId, message: "Anlagentopologie synchronisiert." }); } async function prognosisTelemetryRequest(installationId, telemetry) { if (!prognosisApiUrl || !prognosisServiceToken) { throw Object.assign(new Error("prognosis_not_configured"), { status: 503, publicMessage: "Die Prognoseanbindung ist noch nicht konfiguriert." }); } let upstream; try { upstream = await fetch( `${prognosisApiUrl}/internal/v1/prognosis/${encodeURIComponent(installationId)}/telemetry`, { method: "POST", headers: { "Content-Type": "application/json", "X-Enelix-Service-Token": prognosisServiceToken }, body: JSON.stringify(telemetry), signal: AbortSignal.timeout(6000) } ); } catch (error) { console.error("prognosis_telemetry_unavailable", error.name); throw Object.assign(new Error("prognosis_telemetry_unavailable"), { status: 503, publicMessage: "Die Telemetrie konnte momentan nicht übertragen werden." }); } const result = await upstream.json().catch(() => ({})); if (!upstream.ok) { console.error("prognosis_telemetry_failed", upstream.status); throw Object.assign(new Error("prognosis_telemetry_failed"), { status: upstream.status >= 400 && upstream.status < 500 ? 400 : 503, publicMessage: upstream.status >= 400 && upstream.status < 500 ? "Die Telemetrie enthält ungültige Werte." : "Die Telemetrie konnte momentan nicht übertragen werden." }); } return result; } function validateManagerTelemetry(telemetry, installationId) { if (!telemetry || typeof telemetry !== "object" || telemetry.version !== "1.0" || telemetry.installationId !== installationId) { return "Telemetrieversion oder Installations-ID ist ungültig."; } if (typeof telemetry.capturedAt !== "string") { return "Der Telemetrie-Zeitstempel fehlt."; } const capturedAt = Date.parse(telemetry.capturedAt); const now = Date.now(); if (!Number.isFinite(capturedAt) || capturedAt < now - 24 * 60 * 60 * 1000 || capturedAt > now + 5 * 60 * 1000) { return "Der Telemetrie-Zeitstempel ist ungültig."; } const values = telemetry.values; const fields = ["PV", "Hausverbrauch", "Netzleistung", "SOC"]; if (!values || typeof values !== "object" || Array.isArray(values) || Object.keys(values).length !== fields.length || !fields.every((field) => Object.hasOwn(values, field))) { return "Die Telemetrie muss PV, Hausverbrauch, Netzleistung und SOC enthalten."; } if (!fields.every((field) => typeof values[field] === "number" && Number.isFinite(values[field]))) { return "Alle Telemetriewerte müssen endliche Zahlen sein."; } if (values.PV < 0 || values.PV > 1e9 || values.Hausverbrauch < 0 || values.Hausverbrauch > 1e9 || Math.abs(values.Netzleistung) > 1e9 || values.SOC < 0 || values.SOC > 100) { return "Mindestens ein Telemetriewert liegt ausserhalb des zulässigen Bereichs."; } return null; } async function uploadManagerTelemetry(req, res, installationId) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(installationId)) { return json(res, 400, { error: "Die Installations-ID muss eine UUIDv4 sein." }); } if (!checkDeviceRate(req, installationId)) { return json(res, 429, { error: "Zu viele Geräteanfragen. Bitte später erneut versuchen." }); } const activation = deviceActivation(req, res, installationId); if (!activation) return; const telemetry = await bodyJson(req); const validationError = validateManagerTelemetry(telemetry, installationId); if (validationError) return json(res, 400, { error: validationError }); const result = await prognosisTelemetryRequest(installationId, telemetry); statements.touchTelemetry.run(activation.order_id, installationId); return json(res, 200, { status: result.status || "stored", installationId, writtenFields: result.writtenFields || 4, message: "Telemetrie gespeichert." }); } function normalizeFaultSnapshot(snapshot, installationId) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot) || snapshot.version !== "1.0" || snapshot.installationId !== installationId) { throw new Error("Snapshot-Version oder Installations-ID ist ungültig."); } const capturedAt = Date.parse(snapshot.capturedAt); const now = Date.now(); if (!Number.isFinite(capturedAt) || capturedAt < now - 24 * 60 * 60 * 1000 || capturedAt > now + 5 * 60 * 1000) { throw new Error("Der Snapshot-Zeitstempel ist ungültig."); } const manager = snapshot.manager; if (!manager || typeof manager !== "object" || Array.isArray(manager) || !Number.isInteger(manager.instanceId) || manager.instanceId <= 0 || !["standalone", "main", "submanager"].includes(manager.role) || typeof manager.active !== "boolean") { throw new Error("Die Managerangaben sind ungültig."); } if (!Array.isArray(snapshot.faults) || snapshot.faults.length > 100) { throw new Error("Der Snapshot darf höchstens 100 Störungen enthalten."); } const fields = ["sourceType", "sourceId", "sourceName", "code", "severity", "message"]; const limits = { sourceType: 40, sourceId: 120, sourceName: 160, code: 120, severity: 8, message: 500 }; const seen = new Set(); const faults = snapshot.faults.map((fault) => { if (!fault || typeof fault !== "object" || Array.isArray(fault) || Object.keys(fault).length !== fields.length || !fields.every((field) => Object.hasOwn(fault, field) && typeof fault[field] === "string")) { throw new Error("Mindestens eine Störung hat ungültige Felder."); } const normalized = Object.fromEntries(fields.map((field) => [ field, fault[field].replace(/\s+/g, " ").trim() ])); if (fields.some((field) => !normalized[field] || normalized[field].length > limits[field]) || !["critical", "error", "warning", "info"].includes(normalized.severity)) { throw new Error("Mindestens eine Störung enthält ungültige Werte."); } const key = normalized.sourceId + "\u0000" + normalized.code; if (seen.has(key)) throw new Error("Eine Störung ist im Snapshot mehrfach enthalten."); seen.add(key); return normalized; }); faults.sort((a, b) => (a.sourceId + "\u0000" + a.code).localeCompare(b.sourceId + "\u0000" + b.code)); return { capturedAt: new Date(capturedAt).toISOString(), manager: { instanceId: manager.instanceId, role: manager.role, active: manager.active }, faults }; } function storeFaultSnapshot(activation, installationId, snapshot) { const receivedAt = new Date().toISOString(); const existing = new Map( statements.faultRowsForUpdate.all(activation.plant_id) .map((row) => [row.source_id + "\u0000" + row.code, row]) ); const incoming = new Set(); const opened = []; const resolved = []; const snapshotHash = createHash("sha256") .update(JSON.stringify({ manager: snapshot.manager, faults: snapshot.faults })) .digest("hex"); db.exec("BEGIN IMMEDIATE"); try { for (const fault of snapshot.faults) { const key = fault.sourceId + "\u0000" + fault.code; incoming.add(key); const row = existing.get(key); if (row) { statements.updateFault.run( installationId, fault.sourceType, fault.sourceName, fault.severity, fault.message, receivedAt, row.id ); } else { const inserted = statements.insertFault.run( activation.plant_id, installationId, fault.sourceType, fault.sourceId, fault.sourceName, fault.code, fault.severity, fault.message, receivedAt, receivedAt ); opened.push({ id: Number(inserted.lastInsertRowid), ...fault, firstSeenAt: receivedAt }); } } for (const [key, row] of existing) { if (incoming.has(key)) continue; statements.resolveFault.run(receivedAt, receivedAt, row.id); resolved.push({ id: row.id, sourceType: row.source_type, sourceId: row.source_id, sourceName: row.source_name, code: row.code, severity: row.severity, message: row.message, firstSeenAt: row.first_seen_at, resolvedAt: receivedAt }); } statements.upsertFaultState.run( activation.plant_id, installationId, snapshot.capturedAt, receivedAt, snapshot.manager.instanceId, snapshot.manager.role, snapshot.manager.active ? 1 : 0, snapshotHash, snapshot.faults.length ); if (opened.length || resolved.length) { const plant = statements.faultPlant.get(activation.plant_id); const eventKey = createHash("sha256").update(JSON.stringify({ plantId: activation.plant_id, opened: opened.map((fault) => fault.id), resolved: resolved.map((fault) => fault.id) })).digest("hex"); const deliveries = new Map(); for (const recipient of statements.faultNotificationRecipients.all(activation.plant_id)) { deliveries.set(recipient.email.toLowerCase(), { email: recipient.email, opened, resolved }); } for (const delivery of platformFaultDeliveries(db, activation.plant_id, opened, resolved)) { const key = delivery.email.toLowerCase(); const current = deliveries.get(key) || { email: delivery.email, opened: [], resolved: [] }; for (const fault of delivery.opened) { if (!current.opened.some((item) => item.id === fault.id)) current.opened.push(fault); } for (const fault of delivery.resolved) { if (!current.resolved.some((item) => item.id === fault.id)) current.resolved.push(fault); } deliveries.set(key, current); } const dueAt = Math.floor(Date.now() / 1000); for (const delivery of deliveries.values()) { const payload = JSON.stringify({ plantName: plant?.name || "Enelix Anlage", installationId, receivedAt, activeCount: snapshot.faults.length, opened: delivery.opened, resolved: delivery.resolved }); statements.insertFaultNotification.run( activation.plant_id, delivery.email, eventKey, payload, dueAt ); } } db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return { receivedAt, opened: opened.length, resolved: resolved.length }; } async function uploadManagerFaults(req, res, installationId) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(installationId)) { return json(res, 400, { error: "Die Installations-ID muss eine UUIDv4 sein." }); } if (!checkDeviceRate(req, installationId)) { return json(res, 429, { error: "Zu viele Geräteanfragen. Bitte später erneut versuchen." }); } const activation = deviceActivation(req, res, installationId); if (!activation) return; let snapshot; try { snapshot = normalizeFaultSnapshot(await bodyJson(req), installationId); } catch (error) { return json(res, 400, { error: error.message }); } const stored = storeFaultSnapshot(activation, installationId, snapshot); return json(res, 200, { status: "synchronized", installationId, activeFaults: snapshot.faults.length, openedFaults: stored.opened, resolvedFaults: stored.resolved, receivedAt: stored.receivedAt }); } function faultForApi(row) { return { sourceType: row.source_type, sourceId: row.source_id, sourceName: row.source_name, code: row.code, severity: row.severity, message: row.message, firstSeenAt: row.first_seen_at, lastSeenAt: row.last_seen_at, ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}) }; } function readPlantFaults(req, res, plantId) { const session = requireSession(req, res); if (!session) return; const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "System nicht gefunden." }); const license = ownedLicenseState(plant.id); const licensePlan = { catalogKey: "fault_monitoring", enforcement: "planned", renewal: "annual" }; if (!license.managerVariant) { return json(res, 200, { status: "license_required", active: [], history: [], license: licensePlan }); } if (!plant.installation_id) { return json(res, 200, { status: "not_connected", active: [], history: [], license: licensePlan }); } const state = statements.faultStateByPlant.get(plant.id); if (!state || state.installation_id !== plant.installation_id) { return json(res, 200, { status: "no_data", active: [], history: [], license: licensePlan }); } const lastReceived = Date.parse(state.last_received_at); const online = Number.isFinite(lastReceived) && Date.now() - lastReceived <= 15 * 60 * 1000; return json(res, 200, { status: online ? "available" : "offline", installationId: state.installation_id, capturedAt: state.captured_at, lastReceivedAt: state.last_received_at, manager: { instanceId: state.manager_instance_id, role: state.manager_role, active: Boolean(state.manager_active) }, active: statements.activeFaultsByPlant.all(plant.id).map(faultForApi), history: statements.resolvedFaultsByPlant.all(plant.id).map(faultForApi), license: licensePlan }); } function normalizeNotificationEmail(value) { const email = cleanText(value, 254).toLowerCase(); return /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email) ? email : ""; } function readFaultNotificationRecipients(req, res, plantId) { const session = requireSession(req, res); if (!session) return; const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "System nicht gefunden." }); return json(res, 200, { recipients: statements.faultNotificationRecipients.all(plant.id).map((row) => ({ email: row.email, createdAt: row.created_at })), canManage: roleAllowed(session, ["owner", "admin", "operator"]), mailConfigured: mailConfigured(), limit: 10 }); } async function addFaultNotificationRecipient(req, res, plantId) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "operator"])) { return json(res, 403, { error: "Keine Berechtigung für Benachrichtigungsempfänger." }); } const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "System nicht gefunden." }); const email = normalizeNotificationEmail((await bodyJson(req)).email); if (!email) return json(res, 400, { error: "Bitte eine gültige E-Mail-Adresse eingeben." }); const recipients = statements.faultNotificationRecipients.all(plant.id); if (recipients.some((recipient) => recipient.email.toLowerCase() === email)) { return json(res, 409, { error: "Diese E-Mail-Adresse ist bereits eingetragen." }); } if (Number(statements.faultNotificationRecipientCount.get(plant.id).count) >= 10) { return json(res, 409, { error: "Pro Anlage sind höchstens zehn Empfänger möglich." }); } statements.insertFaultNotificationRecipient.run(plant.id, email, session.user_id); return json(res, 201, { email, message: "Empfänger wurde hinzugefügt." }); } async function deleteFaultNotificationRecipient(req, res, plantId) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "operator"])) { return json(res, 403, { error: "Keine Berechtigung für Benachrichtigungsempfänger." }); } const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "System nicht gefunden." }); const email = normalizeNotificationEmail((await bodyJson(req)).email); if (!email) return json(res, 400, { error: "Bitte eine gültige E-Mail-Adresse eingeben." }); db.exec("BEGIN IMMEDIATE"); try { statements.deletePendingFaultNotifications.run(plant.id, email); statements.deleteFaultNotificationRecipient.run(plant.id, email); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 200, { message: "Empfänger wurde entfernt." }); } function faultSeverityLabel(value) { return { critical: "Kritisch", error: "Fehler", warning: "Warnung", info: "Info" }[value] || value; } function buildFaultNotificationMail(payload) { const opened = Array.isArray(payload.opened) ? payload.opened : []; const resolved = Array.isArray(payload.resolved) ? payload.resolved : []; const plantName = cleanText(payload.plantName, 160) || "Enelix Anlage"; const portalLink = `${publicBaseUrl}/#faults`; const subject = opened.length ? `[Enelix] ${opened.length} neue ${opened.length === 1 ? "Störung" : "Störungen"} – ${plantName}` : `[Enelix] ${resolved.length === 1 ? "Störung behoben" : resolved.length + " Störungen behoben"} – ${plantName}`; const textRows = (faults) => faults.map((fault) => `- [${faultSeverityLabel(fault.severity)}] ${fault.sourceName}: ${fault.message} (${fault.code})` ).join("\n"); const htmlRows = (faults) => ``; const textParts = [`Störungsänderung für ${plantName}.`]; const htmlParts = [`

Störungsänderung für ${escapeHtml(plantName)}.

`]; if (opened.length) { textParts.push(`Neu aufgetreten:\n${textRows(opened)}`); htmlParts.push("

Neu aufgetreten

", htmlRows(opened)); } if (resolved.length) { textParts.push(`Behoben:\n${textRows(resolved)}`); htmlParts.push("

Behoben

", htmlRows(resolved)); } textParts.push(`Aktuell aktive Störungen: ${Number(payload.activeCount || 0)}`, `Portal: ${portalLink}`); htmlParts.push( `

Aktuell aktive Störungen: ${Number(payload.activeCount || 0)}

`, `

Störüberwachung öffnen

` ); return { subject, text: textParts.join("\n\n"), html: htmlParts.join("") }; } let faultNotificationWorkerRunning = false; async function processFaultNotificationOutbox() { if (faultNotificationWorkerRunning || !mailConfigured()) return; faultNotificationWorkerRunning = true; try { const now = Math.floor(Date.now() / 1000); for (const row of statements.dueFaultNotifications.all(now)) { try { const message = buildFaultNotificationMail(JSON.parse(row.payload_json)); await sendMail({ to: row.recipient_email, ...message }); statements.markFaultNotificationSent.run(row.id); } catch (error) { const attempts = Number(row.attempts || 0) + 1; const delay = Math.min(3600, 30 * (2 ** Math.min(attempts - 1, 7))); const detail = cleanText(error.message, 300) || "mail_failed"; statements.retryFaultNotification.run(attempts, now + delay, detail, row.id); console.error("fault_notification_failed", row.id, detail); } } } finally { faultNotificationWorkerRunning = false; } } const faultNotificationTimer = setInterval(() => { processFaultNotificationOutbox().catch((error) => console.error("fault_notification_worker_failed", error.message) ); }, 15000); faultNotificationTimer.unref(); setTimeout(() => processFaultNotificationOutbox().catch(() => {}), 2000).unref(); async function prognosisScheduleRequest(installationId, exportLimitW) { if (!prognosisApiUrl || !prognosisServiceToken) { throw Object.assign(new Error("prognosis_not_configured"), { status: 503, publicMessage: "Die Prognoseanbindung ist noch nicht konfiguriert." }); } const query = new URLSearchParams({ export_limit_w: String(exportLimitW) }); const upstream = await fetch( `${prognosisApiUrl}/internal/v1/prognosis/${encodeURIComponent(installationId)}/schedule?${query}`, { headers: { "X-Enelix-Service-Token": prognosisServiceToken }, signal: AbortSignal.timeout(6000) } ); const result = await upstream.json().catch(() => ({})); if (!upstream.ok) { console.error("prognosis_schedule_failed", upstream.status); throw Object.assign(new Error("prognosis_schedule_failed"), { status: upstream.status >= 400 && upstream.status < 500 ? 400 : 503, publicMessage: upstream.status >= 400 && upstream.status < 500 ? "Die Fahrplananfrage enthält ungültige Werte." : "Der Netzfahrplan ist momentan nicht erreichbar." }); } return result; } async function readManagerSchedule(req, res, installationId, url) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(installationId)) { return json(res, 400, { error: "Die Installations-ID muss eine UUIDv4 sein." }); } if (!checkDeviceRate(req, installationId)) { return json(res, 429, { error: "Zu viele Geräteanfragen. Bitte später erneut versuchen." }); } const activation = deviceActivation(req, res, installationId); if (!activation) return; const license = ownedLicenseState(activation.plant_id); if (Number(license.quantities.grid_schedule || 0) < 1) { return json(res, 403, { error: "Der intelligente Netzfahrplan ist nicht lizenziert." }); } const exportLimitW = Number(url.searchParams.get("exportLimitW") || 0); if (!Number.isFinite(exportLimitW) || exportLimitW < 0 || exportLimitW > 1e9) { return json(res, 400, { error: "Die Einspeisegrenze ist ungültig." }); } const result = await prognosisScheduleRequest(installationId, exportLimitW); return json(res, 200, result); } function configuredPrognosisPlant(req, res, plantId, csrf = false) { const session = requireSession(req, res, csrf); if (!session) return null; const plant = scopedPlant(session, plantId); if (!plant) { json(res, 404, { error: "System nicht gefunden." }); return null; } const license = ownedLicenseState(plant.id); if (!license.managerVariant) { json(res, 409, { error: "Für dieses System ist eine aktive Managerlizenz erforderlich." }); return null; } if (!Object.values(forecastCapabilities(license)).some(Boolean)) { json(res, 409, { error: "Für dieses System ist eine aktive Prognoselizenz erforderlich." }); return null; } if (!plant.installation_id) { json(res, 409, { error: "Der Manager muss zuerst mit dem Lizenzcode verbunden werden." }); return null; } return { session, plant, license }; } async function readPrognosisConfiguration(req, res, plantId) { const access = configuredPrognosisPlant(req, res, plantId); if (!access) return; try { const result = await prognosisConfigurationRequest(access.plant, "GET"); return json(res, 200, { ...result, forecastCapabilities: forecastCapabilities(access.license) }); } catch (error) { const status = Number(error.status) || 503; return json(res, status, { error: error.publicMessage || "Die Prognosekonfiguration ist momentan nicht erreichbar." }); } } async function savePrognosisConfiguration(req, res, plantId) { const access = configuredPrognosisPlant(req, res, plantId, true); if (!access) return; if (!roleAllowed(access.session, ["owner", "admin", "operator"])) { return json(res, 403, { error: "Keine Berechtigung zum Konfigurieren der Prognose." }); } const configuration = applyLicensedForecastSelection( await bodyJson(req), forecastCapabilities(access.license) ); try { const result = await prognosisConfigurationRequest(access.plant, "PUT", configuration); return json(res, 200, { ...result, forecastCapabilities: forecastCapabilities(access.license), message: "Prognosekonfiguration gespeichert." }); } catch (error) { const status = Number(error.status) || 503; return json(res, status, { error: error.publicMessage || "Die Prognosekonfiguration konnte nicht gespeichert werden." }); } } function ownedSetupState(plantId) { const quantities = {}; for (const row of statements.paidSetupByPlant.all(plantId)) { quantities[row.catalog_key] = Number(row.quantity || 0); } return { manager: Number(quantities.manager_standard || 0) > 0 || Number(quantities.manager_peak || 0) > 0, quantities }; } function purchaseProposal(plantId, recommendation) { const owned = ownedLicenseState(plantId); const setup = ownedSetupState(plantId); const modules = {}; const setupModules = {}; const coverage = licenseCoverage(recommendation.modules, owned.quantities); for (const [key, requested] of Object.entries(recommendation.modules || {})) { modules[key] = Math.max(0, Number(requested || 0) - Number(coverage[key] || 0)); setupModules[key] = Math.max(0, Number(requested || 0) - Number(setup.quantities[key] || 0)); } let managerAction = null; if (!owned.managerVariant) managerAction = "purchase"; else if (owned.managerVariant === "manager_standard" && recommendation.managerVariant === "manager_peak") managerAction = "upgrade"; const managerSetup = !setup.manager; return { managerVariant: recommendation.managerVariant, currentManagerVariant: owned.managerVariant, managerAction, modules, setup: { manager: managerSetup, modules: setupModules }, required: Boolean( managerAction || managerSetup || Object.values(modules).some((quantity) => quantity > 0) || Object.values(setupModules).some((quantity) => quantity > 0) ) }; } async function addPlant(req, res) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "operator"])) return json(res, 403, { error: "Keine Berechtigung zum Erstellen von Anlagen." }); const body = await bodyJson(req); const plant = { id: randomUUID(), name: cleanText(body.name, 100), type: cleanText(body.type, 40), installation: cleanText(body.installation, 80) || null, location: cleanText(body.location, 120), manager: cleanText(body.manager, 30) }; if (!plant.name || !plant.location) return json(res, 400, { error: "Bitte Name und Standort ausfüllen." }); if (plant.installation && !/^[A-Za-z0-9._:-]{3,80}$/.test(plant.installation)) return json(res, 400, { error: "Die Installations-ID enthält ungültige Zeichen." }); if (!["manager_standard", "manager_peak", "billing_manager"].includes(plant.manager)) return json(res, 400, { error: "Bitte einen gültigen Lizenztyp wählen." }); try { statements.insertPlant.run( plant.id, session.organization_id || null, session.organization_id ? null : session.user_id, plant.name, plant.type || "Gewerbe", plant.installation, plant.location, plant.manager, "licenses", null, plant.installation ? "Verknüpft" : "Nicht verknüpft" ); } catch (error) { if (String(error.message).includes("UNIQUE")) return json(res, 409, { error: "Diese Installations-ID wird bereits verwendet." }); throw error; } const saved = scopedPlant(session, plant.id); json(res, 201, { plant: saved }); } async function deletePlant(req, res, plantId) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin"])) return json(res, 403, { error: "Nur Eigentümer und Administratoren dürfen Anlagen löschen." }); const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "Anlage nicht gefunden." }); const body = await bodyJson(req); const confirmedName = cleanText(body.plantName, 100); if (confirmedName !== plant.name) return json(res, 400, { error: "Der Anlagenname stimmt nicht mit der ausgewählten Anlage überein." }); const paidOrderCount = Number(statements.paidOrderCountByPlant.get(plant.id)?.count || 0); if (paidOrderCount > 0 && body.deletePaidLicenses !== true) { return json(res, 409, { error: "Für diese Anlage bestehen bezahlte Lizenzen. Bitte das Löschen der Anlage und der zugehörigen Lizenzdaten nochmals bestätigen.", requiresPaidConfirmation: true, paidOrderCount }); } db.exec("BEGIN IMMEDIATE"); try { statements.deleteActivationsByPlant.run(plant.id); statements.deleteEntitlementsByPlant.run(plant.id); statements.deletePaymentsByPlant.run(plant.id); statements.deleteOrdersByPlant.run(plant.id); statements.deleteConfigurationsByPlant.run(plant.id); statements.deletePlantById.run(plant.id); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 200, { deleted: true, paidLicensesDeleted: paidOrderCount, message: paidOrderCount ? "Die Anlage und ihre zugehörigen Lizenzdaten wurden gelöscht." : "Die Anlage wurde gelöscht." }); } function scopedConfiguration(session, configurationId) { return session.organization_id ? statements.configurationOrganization.get(configurationId, session.organization_id) : statements.configurationPersonal.get(configurationId, session.user_id); } function systemPackageFilename(configuration) { const slug = configuration.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "system"; return `enelix-${slug}.zip`; } async function addSystemConfiguration(req, res) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "operator"])) return json(res, 403, { error: "Keine Berechtigung zum Konfigurieren von Systemen." }); const body = await bodyJson(req); const requestedPlantId = cleanText(body.plantId, 80); const existingPlant = requestedPlantId ? scopedPlant(session, requestedPlantId) : null; if (requestedPlantId && !existingPlant) return json(res, 404, { error: "Lizenzpaket nicht gefunden." }); const configuration = normalizeSystemConfiguration(body); const recommendation = recommendLicenses(configuration); const existingConfiguration = existingPlant ? statements.configurationByPlant.get(existingPlant.id) : null; let previousConfiguration = null; try { previousConfiguration = existingConfiguration ? JSON.parse(existingConfiguration.configuration_json) : null; } catch (_) { previousConfiguration = null; } const configurationId = existingConfiguration?.id || randomUUID(); const plantId = existingPlant?.id || randomUUID(); const previousCostReportPlantId = cleanText(previousConfiguration?.costReport?.plantId, 80); const existingCostReportPlant = previousCostReportPlantId ? scopedPlant(session, previousCostReportPlantId) : null; const costReportPlantId = configuration.costReport.enabled ? (existingCostReportPlant?.id || randomUUID()) : null; if (costReportPlantId) configuration.costReport.plantId = costReportPlantId; db.exec("BEGIN IMMEDIATE"); try { if (existingPlant) { statements.updateConfiguredPlant.run( configuration.name, configuration.siteType, configuration.location, configurationId, plantId ); } else { statements.insertPlant.run( plantId, session.organization_id || null, session.organization_id ? null : session.user_id, configuration.name, configuration.siteType, null, configuration.location, recommendation.managerVariant, "configured", configurationId, "Konfiguriert" ); } if (costReportPlantId) { const costReportName = `${configuration.name} – Verbrauchskosten`; if (existingCostReportPlant) { statements.updateCostReportPlant.run(costReportName, configuration.location, costReportPlantId); } else { statements.insertPlant.run( costReportPlantId, session.organization_id || null, session.organization_id ? null : session.user_id, costReportName, "Abrechnung", null, configuration.location, "billing_manager", "configured", null, "Konfiguriert" ); } } statements.insertConfiguration.run( configurationId, session.user_id, session.organization_id || null, plantId, JSON.stringify(configuration), JSON.stringify(recommendation) ); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } const proposal = purchaseProposal(plantId, recommendation); const packageReady = !proposal.required; let delivered = false; if (packageReady && mailConfigured()) { try { await sendMail({ to: session.email, subject: `Enelix Einrichtungspaket für ${configuration.name}`, text: `Guten Tag ${session.display_name},\n\nim Anhang erhalten Sie das lizenzierte Einrichtungspaket für ${configuration.name}. Bewahren Sie diese Datei als Sicherung Ihrer Konfiguration auf.\n\nDas Paket enthält ausschließlich die durch bezahlte Lizenzen freigegebenen Module.`, html: `

Guten Tag ${escapeHtml(session.display_name)},

im Anhang erhalten Sie das lizenzierte Einrichtungspaket für ${escapeHtml(configuration.name)}. Bewahren Sie diese Datei als Sicherung Ihrer Konfiguration auf.

Das Paket enthält ausschließlich die durch bezahlte Lizenzen freigegebenen Module.

`, attachments: [{ filename: systemPackageFilename(configuration), contentType: "application/zip", content: buildSymconPackage(configuration, recommendation) }] }); delivered = true; } catch (error) { console.error("configuration_package_email_failed", error.message); } } return json(res, existingPlant ? 200 : 201, { configurationId, plant: scopedPlant(session, plantId), costReportPlant: costReportPlantId ? scopedPlant(session, costReportPlantId) : null, configuration, recommendation, purchaseProposal: proposal, packageReady, delivered, packageUrl: packageReady ? `/api/configurations/${configurationId}/package` : null }); } function downloadSystemPackage(req, res, configurationId) { const session = requireSession(req, res); if (!session) return; const record = scopedConfiguration(session, configurationId); if (!record) return json(res, 404, { error: "Systemkonfiguration nicht gefunden." }); const configuration = JSON.parse(record.configuration_json); const recommendation = JSON.parse(record.recommendation_json); const proposal = purchaseProposal(record.plant_id, recommendation); if (proposal.required) { return json(res, 409, { error: "Das Einrichtungspaket wird erst freigegeben, wenn alle konfigurierten Module lizenziert sind." }); } const content = buildSymconPackage(configuration, recommendation); return binary(res, 200, content, "application/zip", systemPackageFilename(configuration)); } function pdfText(value) { return String(value ?? "") .normalize("NFKD") .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue") .replace(/Ä/g, "Ae").replace(/Ö/g, "Oe").replace(/Ü/g, "Ue").replace(/ß/g, "ss") .replace(/[^\x20-\x7e]/g, "") .replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); } function buildPaymentReceipt(order, plant, lines, session) { const commands = []; const writeLine = (y, size, text) => commands.push(`BT /F1 ${size} Tf 56 ${y} Td (${pdfText(text)}) Tj ET`); writeLine(790, 20, "enelix Transaktionsbeleg"); writeLine(765, 10, "BELEVO AG / Enelix EMS Portal"); writeLine(730, 11, `Beleg: ${order.id.slice(0, 8).toUpperCase()}`); writeLine(712, 10, `Zahlungsdatum: ${String(order.paid_at || order.created_at).slice(0, 10)}`); writeLine(694, 10, `Konto: ${session.organization_name || session.display_name}`); writeLine(676, 10, `System: ${plant?.name || order.plant_id}`); writeLine(646, 11, "Positionen"); let y = 626; for (const line of lines.slice(0, 18)) { const description = String(line.description || line.catalog_key).slice(0, 58); writeLine(y, 9, `${line.quantity} x ${description} - CHF ${(line.unit_amount * line.quantity / 100).toFixed(2)}`); y -= 16; } writeLine(Math.max(285, y - 12), 12, `Bezahlt: CHF ${(order.amount_total / 100).toFixed(2)}`); writeLine(255, 9, "Preise netto. Dieser Beleg dokumentiert die im Portal bestaetigte Bestellung."); writeLine(239, 9, "Kein MWST-Ausweis. Fuer eine steuerliche Rechnung gelten die spaeter hinterlegten Rechnungsdaten."); writeLine(205, 9, `Transaktionsreferenz: ${order.stripe_payment_intent_id || order.stripe_checkout_session_id || "-"}`); const stream = commands.join("\n"); const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", `<< /Length ${Buffer.byteLength(stream, "ascii")} >>\nstream\n${stream}\nendstream` ]; let document = "%PDF-1.4\n"; const offsets = [0]; objects.forEach((object, index) => { offsets.push(Buffer.byteLength(document, "ascii")); document += `${index + 1} 0 obj\n${object}\nendobj\n`; }); const xref = Buffer.byteLength(document, "ascii"); document += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; offsets.slice(1).forEach((offset) => { document += `${String(offset).padStart(10, "0")} 00000 n \n`; }); document += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return Buffer.from(document, "ascii"); } function buildActivationDocument(code, order, plant, session, replacement = false) { const commands = []; const writeLine = (y, size, text) => commands.push(`BT /F1 ${size} Tf 56 ${y} Td (${pdfText(text)}) Tj ET`); writeLine(790, 20, "enelix Aktivierungscode"); writeLine(765, 10, "BELEVO AG / Enelix EMS Portal"); writeLine(720, 11, `Konto: ${session.organization_name || session.display_name}`); writeLine(698, 11, `System: ${plant?.name || order.plant_id}`); writeLine(676, 10, `Bestellung: ${order.id.slice(0, 8).toUpperCase()}`); writeLine(620, 18, code); writeLine(575, 10, replacement ? "Dieser Ersatzcode macht den bisherigen Lizenzcode ungueltig." : "Diesen Code einmalig im Enelix Manager eingeben."); writeLine(555, 10, replacement && plant?.installation_id ? "Die bisherige Verbindung wurde geloest. Der Ersatzcode kann neu gebunden werden." : "Bei der ersten Aktivierung wird er fest mit der Symcon-Installation verbunden."); writeLine(515, 9, "Der Portalserver speichert nur einen kryptografischen Hash des Codes."); writeLine(499, 9, "Bewahren Sie dieses Dokument sicher auf und geben Sie den Code nicht an Dritte weiter."); const stream = commands.join("\n"); const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", `<< /Length ${Buffer.byteLength(stream, "ascii")} >>\nstream\n${stream}\nendstream` ]; let document = "%PDF-1.4\n"; const offsets = [0]; objects.forEach((object, index) => { offsets.push(Buffer.byteLength(document, "ascii")); document += `${index + 1} 0 obj\n${object}\nendobj\n`; }); const xref = Buffer.byteLength(document, "ascii"); document += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; offsets.slice(1).forEach((offset) => { document += `${String(offset).padStart(10, "0")} 00000 n \n`; }); document += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return Buffer.from(document, "ascii"); } function downloadPaymentReceipt(req, res, orderId) { const session = requireSession(req, res); if (!session) return; const order = scopedOrder(session, orderId); if (!order || order.status !== "paid") return json(res, 404, { error: "Für diese Bestellung ist kein Zahlungsbeleg verfügbar." }); const plant = scopedPlant(session, order.plant_id); const lines = statements.orderLines.all(order.id); const receipt = buildPaymentReceipt(order, plant, lines, session); return binary(res, 200, receipt, "application/pdf", `enelix-zahlungsbeleg-${order.id.slice(0, 8)}.pdf`); } function listOrders(req, res) { const session = requireSession(req, res); if (!session) return; const orders = (session.organization_id ? statements.ordersOrganization.all(session.organization_id) : statements.ordersPersonal.all(session.user_id) ).map((order) => ({ ...order, lines: statements.orderLines.all(order.id), entitlements: statements.entitlementByOrder.all(order.id) })); return json(res, 200, { orders }); } function scopedOrder(session, orderId) { return session.organization_id ? statements.orderOrganization.get(orderId, session.organization_id) : statements.orderPersonal.get(orderId, session.user_id); } async function issueActivationCode(req, res, orderId) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "billing"])) return json(res, 403, { error: "Keine Berechtigung für Lizenzcodes." }); const order = scopedOrder(session, orderId); if (!order) return json(res, 404, { error: "Bestellung nicht gefunden." }); if (order.status !== "paid") return json(res, 409, { error: "Der Lizenzcode wird erst nach bestätigter Zahlung freigegeben." }); if (!statements.orderLines.all(order.id).some((line) => line.line_type === "license")) { return json(res, 409, { error: "Diese Bestellung enthält ausschließlich die Ersteinrichtung und benötigt keinen Lizenzcode." }); } const body = await bodyJson(req); const existing = statements.activationByOrder.get(order.id); if (existing && !body.rotate) { return json(res, 409, { error: `Ein Lizenzcode wurde bereits erzeugt (${existing.code_hint}). Er kann bei Verlust gezielt ersetzt werden.` }); } if (existing?.installation_id && !roleAllowed(session, ["owner", "admin"])) { return json(res, 403, { error: "Nur Eigentümer und Administratoren dürfen eine verbundene Lizenz auf eine neue Installation übertragen." }); } if (!mailConfigured()) return json(res, 503, { error: "Der E-Mail-Versand ist momentan nicht verfügbar. Der bestehende Lizenzcode bleibt gültig." }); if (activationRotations.has(order.id)) return json(res, 409, { error: "Für diese Lizenz wird bereits ein Ersatzcode erstellt." }); activationRotations.add(order.id); try { const code = createActivationCode(); const normalized = normalizeActivationCode(code); const hint = `ENX-XXXX-XXXX-XXXX-${code.slice(-4)}`; const plant = scopedPlant(session, order.plant_id); const replacement = Boolean(existing); const replacementNotice = replacement ? "Der bisherige Lizenzcode wird nach erfolgreichem Versand ungültig. Eine bestehende Installationsbindung wird gelöst; der Ersatzcode kann danach einmalig mit einer neuen Symcon-Installation verbunden werden." : "Der vollständige Code wird im Portal nicht gespeichert und kann dort nicht erneut angezeigt werden."; try { await sendMail({ to: session.email, subject: `${replacement ? "Enelix Ersatzcode" : "Enelix Aktivierungscode"} für ${plant?.name || "Ihr System"}`, text: `Guten Tag ${session.display_name},\n\nim Anhang erhalten Sie ${replacement ? "den neuen Ersatzcode" : "den Aktivierungscode"} für ${plant?.name || "Ihr Enelix System"}. ${replacementNotice}\n\nBewahren Sie das Dokument sicher auf.`, html: `

Guten Tag ${escapeHtml(session.display_name)},

im Anhang erhalten Sie ${replacement ? "den neuen Ersatzcode" : "den Aktivierungscode"} für ${escapeHtml(plant?.name || "Ihr Enelix System")}.

${escapeHtml(replacementNotice)}

Bewahren Sie das Dokument sicher auf.

`, attachments: [{ filename: `enelix-${replacement ? "ersatzcode" : "aktivierung"}-${order.id.slice(0, 8)}.pdf`, contentType: "application/pdf", content: buildActivationDocument(code, order, plant, session, replacement) }] }); } catch (error) { console.error("activation_code_email_failed", error.message); return json(res, 502, { error: "Der neue Lizenzcode konnte nicht per E-Mail versendet werden. Der bisherige Code bleibt gültig." }); } db.exec("BEGIN IMMEDIATE"); try { if (existing) { statements.rotateActivation.run(tokenHash(normalized), hint, session.user_id, order.id); statements.releasePlantActivation.run(order.plant_id); } else { statements.insertActivation.run(order.id, order.plant_id, tokenHash(normalized), hint, session.user_id); } db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, existing ? 200 : 201, { hint, delivered: true, replaced: Boolean(existing), message: existing ? "Der neue Lizenzcode wurde per E-Mail versendet. Der bisherige Code und seine Installationsbindung sind ab sofort ungültig." : "Der Aktivierungscode wurde als PDF an Ihre Konto-E-Mail-Adresse gesendet." }); } finally { activationRotations.delete(order.id); } } async function revokeActivationCode(req, res, orderId) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin"])) return json(res, 403, { error: "Nur Eigentümer und Administratoren dürfen Lizenzcodes löschen." }); const order = scopedOrder(session, orderId); if (!order) return json(res, 404, { error: "Bestellung nicht gefunden." }); if (order.status !== "paid") return json(res, 409, { error: "Für diese Bestellung besteht kein bezahlter Lizenzcode." }); const body = await bodyJson(req); if (body.confirm !== true) return json(res, 400, { error: "Das Löschen des Lizenzcodes muss bestätigt werden." }); const existing = statements.activationByOrder.get(order.id); if (!existing || existing.code_hint === "Gelöscht") return json(res, 404, { error: "Es ist kein aktiver Lizenzcode vorhanden." }); const tombstoneHash = tokenHash(randomBytes(48).toString("base64url")); statements.revokeActivation.run(tombstoneHash, session.user_id, order.id); return json(res, 200, { deleted: true, message: "Der Lizenzcode wurde gelöscht und ist ab sofort ungültig. Die Lizenz und ihre Installationsbindung bleiben bestehen." }); } async function activateLicense(req, res) { if (!checkActivationRate(req)) return json(res, 429, { error: "Zu viele Aktivierungsversuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const code = normalizeActivationCode(body.code); const installationId = cleanText(body.installationId, 80); const publicKey = cleanText(body.publicKey, 160); const issueDeviceToken = body.issueDeviceToken === true; if (!activationCodeValid(code)) return json(res, 400, { error: "Der Lizenzcode ist ungültig." }); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(installationId)) { return json(res, 400, { error: "Die Installations-ID muss eine UUIDv4 sein." }); } if (publicKey && !/^[A-Za-z0-9_-]{40,160}$/.test(publicKey)) return json(res, 400, { error: "Der öffentliche Geräteschlüssel ist ungültig." }); const activation = statements.activationByCode.get(tokenHash(code)); if (!activation || activation.order_status !== "paid") return json(res, 404, { error: "Lizenzcode nicht gefunden oder noch nicht bezahlt." }); if (activation.installation_id && activation.installation_id !== installationId) { return json(res, 409, { error: "Dieser Lizenzcode ist bereits mit einer anderen Installation verknüpft." }); } const deviceToken = issueDeviceToken ? randomBytes(32).toString("base64url") : null; db.exec("BEGIN IMMEDIATE"); try { statements.bindActivation.run(installationId, publicKey || null, activation.order_id, installationId); if (deviceToken) { statements.setDeviceToken.run(tokenHash(deviceToken), activation.order_id, installationId); } statements.activatePlant.run(installationId, activation.plant_id); statements.createEntitlements.run(activation.order_id); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } const issuedAt = new Date(); const offlineUntil = new Date(issuedAt.getTime() + 14 * 24 * 60 * 60 * 1000); return json(res, 200, { status: "active", development: true, installationId, orderId: activation.order_id, entitlements: statements.activeEntitlementsByPlant.all(activation.plant_id), ...(deviceToken ? { deviceToken } : {}), issuedAt: issuedAt.toISOString(), refreshAfter: new Date(issuedAt.getTime() + 24 * 60 * 60 * 1000).toISOString(), offlineUntil: offlineUntil.toISOString() }); } async function startCheckout(req, res) { const session = requireSession(req, res, true); if (!session) return; if (!roleAllowed(session, ["owner", "admin", "billing"])) return json(res, 403, { error: "Keine Berechtigung für Bestellungen." }); if (!checkCheckoutRate(session.user_id)) return json(res, 429, { error: "Zu viele Checkout-Versuche. Bitte später erneut versuchen." }); const body = await bodyJson(req); const plantId = cleanText(body.plantId, 80); const plant = scopedPlant(session, plantId); if (!plant) return json(res, 404, { error: "Anlage nicht gefunden." }); const configurationId = cleanText(body.configurationId, 80); const configurationRecord = configurationId ? scopedConfiguration(session, configurationId) : null; if (configurationId && (!configurationRecord || configurationRecord.plant_id !== plant.id)) { return json(res, 404, { error: "Systemkonfiguration für diese Anlage nicht gefunden." }); } const recommendation = configurationRecord ? JSON.parse(configurationRecord.recommendation_json) : null; const proposal = recommendation ? purchaseProposal(plant.id, recommendation) : null; const managerVariant = recommendation?.managerVariant || cleanText(body.managerVariant, 40) || plant.manager_variant; const requestedModules = proposal?.modules || body.modules || {}; const setupRequest = proposal?.setup || {}; const owned = ownedLicenseState(plant.id); const catalogSettings = await loadCatalogSettings(); const lines = plant.manager_variant === "billing_manager" ? buildBillingOrderLines( body.modules || {}, owned.quantities, body.setupRequested === true, catalogSettings.items, catalogSettings.vatRate ) : buildOrderLines( managerVariant, requestedModules, setupRequest, owned.managerVariant, catalogSettings.items, catalogSettings.vatRate ); const amountTotal = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0); const orderId = randomUUID(); db.exec("BEGIN IMMEDIATE"); try { statements.insertOrder.run(orderId, session.user_id, session.organization_id || null, plant.id, amountTotal); for (const line of lines) { statements.insertOrderLine.run( orderId, line.sku, line.catalogKey, line.name, line.lineType, line.quantity, line.unitAmount, line.termMonths || null, line.entitlement ? 1 : 0 ); } db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } if (amountTotal === 0) { const paymentReference = `free:${orderId}`; db.exec("BEGIN IMMEDIATE"); try { statements.markFreeOrderPaid.run(paymentReference, orderId); statements.insertFreePayment.run(orderId, paymentReference); statements.createEntitlements.run(orderId); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return json(res, 201, { orderId, checkoutUrl: `${publicBaseUrl}/?checkout=free&order=${encodeURIComponent(orderId)}#account`, free: true }); } if (!stripeConfigured()) { statements.checkoutFailed.run(orderId); return json(res, 503, { error: "Stripe Sandbox ist noch nicht konfiguriert." }); } try { const checkout = await createCheckoutSession({ orderId, userId: session.user_id, email: session.email, lines, successUrl: `${publicBaseUrl}/?checkout=success&order=${encodeURIComponent(orderId)}&session_id={CHECKOUT_SESSION_ID}#account`, cancelUrl: `${publicBaseUrl}/?checkout=cancelled&order=${encodeURIComponent(orderId)}#account` }); statements.checkoutReady.run(checkout.id, orderId); return json(res, 201, { orderId, checkoutUrl: checkout.url, expiresAt: checkout.expiresAt }); } catch (error) { statements.checkoutFailed.run(orderId); if (error.diagnostic) console.error("stripe_checkout_failed", JSON.stringify(error.diagnostic)); throw error; } } async function deliverLicensedConfigurationPackage(order) { if (!mailConfigured()) return false; const record = statements.configurationByPlant.get(order.plant_id); const user = statements.findUserById.get(order.user_id); if (!record || !user) return false; const configuration = JSON.parse(record.configuration_json); const recommendation = JSON.parse(record.recommendation_json); if (purchaseProposal(order.plant_id, recommendation).required) return false; await sendMail({ to: user.email, subject: `Enelix Einrichtungspaket freigegeben für ${configuration.name}`, text: `Guten Tag ${user.display_name},\n\ndie benötigten Lizenzen für ${configuration.name} sind jetzt bezahlt. Im Anhang erhalten Sie das freigegebene Einrichtungspaket.\n\nDas Paket enthält ausschließlich die lizenzierten Module.`, html: `

Guten Tag ${escapeHtml(user.display_name)},

die benötigten Lizenzen für ${escapeHtml(configuration.name)} sind jetzt bezahlt. Im Anhang erhalten Sie das freigegebene Einrichtungspaket.

Das Paket enthält ausschließlich die lizenzierten Module.

`, attachments: [{ filename: systemPackageFilename(configuration), contentType: "application/zip", content: buildSymconPackage(configuration, recommendation) }] }); return true; } function processStripeEvent(event) { const type = String(event.type || ""); const object = event.data?.object || {}; if (!type.startsWith("checkout.session.")) return; const orderId = String(object.metadata?.order_id || object.client_reference_id || ""); const sessionId = String(object.id || ""); if (!orderId || !sessionId) throw new Error("stripe_event_missing_order"); const order = statements.orderById.get(orderId); if (!order || order.stripe_checkout_session_id !== sessionId) throw new Error("stripe_event_order_mismatch"); if (["checkout.session.completed", "checkout.session.async_payment_succeeded"].includes(type)) { if (order.status === "paid") return null; if (object.payment_status !== "paid" && type !== "checkout.session.async_payment_succeeded") { statements.markOrderStatus.run("payment_processing", orderId, sessionId); return; } if (Number(object.amount_total) !== Number(order.amount_total) || String(object.currency || "").toLowerCase() !== order.currency) { throw new Error("stripe_event_amount_mismatch"); } statements.markOrderPaid.run(String(object.payment_intent || ""), orderId, sessionId); statements.insertPayment.run( orderId, String(object.payment_intent || sessionId), Number(object.amount_total), String(object.currency).toLowerCase(), "paid" ); statements.createEntitlements.run(orderId); const managerLine = statements.orderLines.all(orderId).find((line) => line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key) ); if (managerLine) statements.updatePlantManager.run(managerLine.catalog_key, order.plant_id); return order; } if (type === "checkout.session.async_payment_failed") statements.markOrderStatus.run("payment_failed", orderId, sessionId); if (type === "checkout.session.expired") statements.markOrderStatus.run("expired", orderId, sessionId); } async function stripeWebhook(req, res) { const rawBody = await bodyBuffer(req, maxWebhookBytes); if (!verifyStripeSignature(rawBody, req.headers["stripe-signature"])) { return json(res, 400, { error: "Ungültige Stripe-Signatur." }); } let event; try { event = JSON.parse(rawBody.toString("utf8")); } catch { return json(res, 400, { error: "Ungültiges Stripe-Ereignis." }); } if (!/^evt_[A-Za-z0-9_]+$/.test(String(event.id || ""))) return json(res, 400, { error: "Ungültiges Stripe-Ereignis." }); let fulfilledOrder = null; db.exec("BEGIN IMMEDIATE"); try { statements.insertStripeEvent.run(event.id, String(event.type || "unknown")); fulfilledOrder = processStripeEvent(event); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); if (String(error.message).includes("UNIQUE")) return json(res, 200, { received: true, duplicate: true }); throw error; } let packageDelivered = false; if (fulfilledOrder) { try { packageDelivered = await deliverLicensedConfigurationPackage(fulfilledOrder); } catch (error) { console.error("licensed_configuration_package_email_failed", error.message); } } return json(res, 200, { received: true, packageDelivered }); } const mimeTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml" }; async function staticFile(req, res, pathname) { const requested = pathname === "/" ? "/index.html" : pathname; const safePath = normalize(requested).replace(/^(\.\.[/\\])+/, ""); const absolute = join(publicDir, safePath); if (!absolute.startsWith(publicDir)) return json(res, 404, { error: "Nicht gefunden." }); try { const details = await stat(absolute); if (!details.isFile()) throw new Error("not_file"); const content = await readFile(absolute); res.writeHead(200, { "Content-Security-Policy": "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", "Permissions-Policy": "geolocation=(), microphone=(), camera=()", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Content-Type": mimeTypes[extname(absolute)] || "application/octet-stream", "Content-Length": content.length, "Cache-Control": extname(absolute) === ".html" ? "no-store" : "public, max-age=300" }); if (req.method === "HEAD") return res.end(); res.end(content); } catch { json(res, 404, { error: "Nicht gefunden." }); } } // ENELIX_V4_SHADOW_BRIDGE // No dependency or changed route while NETPLAN_V4_URL is absent. const plannerV4Bridge = process.env.NETPLAN_V4_URL ? (await import('./netplan-v4-bridge.mjs')).createPlannerV4Bridge({ configuredPrognosisPlant, roleAllowed, bodyJson, json, deviceActivation, checkDeviceRate, ownedLicenseState, serviceToken: prognosisServiceToken, upstreamUrl: process.env.NETPLAN_V4_URL }) : null; const server = createServer(async (req, res) => { const started = Date.now(); const url = new URL(req.url, "http://localhost"); let status = 200; const originalWriteHead = res.writeHead.bind(res); res.writeHead = function (code, ...args) { status = code; return originalWriteHead(code, ...args); }; try { if (plannerV4Bridge && await plannerV4Bridge(req, res, url)) return; if (url.pathname === "/healthz" && req.method === "GET") return json(res, 200, { status: "ok", email: mailConfigured() ? "configured" : "not_configured", stripe: stripeConfigured() ? "configured" : "not_configured", prognosis: prognosisApiUrl && prognosisServiceToken ? "configured" : "not_configured" }); if (url.pathname === "/api/v1/payments/stripe/webhook" && req.method === "POST") return await stripeWebhook(req, res); if (url.pathname === "/api/v1/licenses/activate" && req.method === "POST") return await activateLicense(req, res); const topologyUploadMatch = url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/topology$/i); if (topologyUploadMatch && req.method === "PUT") return await uploadManagerTopology(req, res, topologyUploadMatch[1]); const telemetryUploadMatch = url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/telemetry$/i); if (telemetryUploadMatch && req.method === "POST") return await uploadManagerTelemetry(req, res, telemetryUploadMatch[1]); const scheduleMatch = url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/schedule$/i); if (scheduleMatch && req.method === "GET") return await readManagerSchedule(req, res, scheduleMatch[1], url); const faultUploadMatch = url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/faults$/i); if (faultUploadMatch && req.method === "PUT") return await uploadManagerFaults(req, res, faultUploadMatch[1]); if (url.pathname === "/api/catalog" && req.method === "GET") { const catalogSettings = await loadCatalogSettings(); return json(res, 200, { currency: "CHF", prices: "net", vatRate: catalogSettings.vatRate, items: publicCatalog(catalogSettings.items) }); } if (url.pathname === "/api/wizard-catalog" && req.method === "GET") { return json(res, 200, publicWizardCatalog()); } if (url.pathname === "/api/session" && req.method === "GET") { const session = getSession(req); return json(res, 200, session ? publicSession(session) : { authenticated: false }); } if (url.pathname === "/api/auth/register" && req.method === "POST") return await register(req, res); if (url.pathname === "/api/auth/login" && req.method === "POST") return await login(req, res); if (url.pathname === "/api/auth/resend-verification" && req.method === "POST") return await resendVerification(req, res); if (url.pathname === "/api/auth/verify-email" && req.method === "POST") return await verifyEmail(req, res); if (url.pathname === "/api/auth/forgot-password" && req.method === "POST") return await forgotPassword(req, res); if (url.pathname === "/api/auth/reset-password" && req.method === "POST") return await resetPassword(req, res); if (url.pathname === "/api/auth/logout" && req.method === "POST") return logout(req, res); if (url.pathname === "/api/account/password" && req.method === "POST") return await changePassword(req, res); if (url.pathname === "/api/organizations" && req.method === "POST") return await createOrganization(req, res); if (url.pathname === "/api/workspace" && req.method === "POST") return await switchWorkspace(req, res); if (url.pathname === "/api/invitations/preview" && req.method === "POST") return await invitationPreview(req, res); if (url.pathname === "/api/invitations/accept" && req.method === "POST") return await acceptInvitation(req, res); if (url.pathname === "/api/invitations" && req.method === "GET") return listInvitations(req, res); if (url.pathname === "/api/invitations" && req.method === "POST") return await inviteToOrganization(req, res); if (url.pathname === "/api/plants" && req.method === "GET") return listPlants(req, res); if (url.pathname === "/api/plants" && req.method === "POST") return await addPlant(req, res); const prognosisConfigurationMatch = url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})\/prognosis\/configuration$/i); if (prognosisConfigurationMatch && req.method === "GET") return await readPrognosisConfiguration(req, res, prognosisConfigurationMatch[1]); if (prognosisConfigurationMatch && req.method === "PUT") return await savePrognosisConfiguration(req, res, prognosisConfigurationMatch[1]); const prognosisMatch = url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})\/prognosis$/i); if (prognosisMatch && req.method === "GET") return await readPlantPrognosis(req, res, prognosisMatch[1], url); const plantFaultsMatch = url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})\/faults$/i); if (plantFaultsMatch && req.method === "GET") return readPlantFaults(req, res, plantFaultsMatch[1]); const faultRecipientsMatch = url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})\/fault-notification-recipients$/i); if (faultRecipientsMatch && req.method === "GET") return readFaultNotificationRecipients(req, res, faultRecipientsMatch[1]); if (faultRecipientsMatch && req.method === "POST") return await addFaultNotificationRecipient(req, res, faultRecipientsMatch[1]); if (faultRecipientsMatch && req.method === "DELETE") return await deleteFaultNotificationRecipient(req, res, faultRecipientsMatch[1]); const plantMatch = url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})$/i); if (plantMatch && req.method === "DELETE") return await deletePlant(req, res, plantMatch[1]); if (url.pathname === "/api/configurations" && req.method === "POST") return await addSystemConfiguration(req, res); const packageMatch = url.pathname.match(/^\/api\/configurations\/([0-9a-f-]{36})\/package$/i); if (packageMatch && req.method === "GET") return downloadSystemPackage(req, res, packageMatch[1]); if (url.pathname === "/api/orders" && req.method === "GET") return listOrders(req, res); const receiptMatch = url.pathname.match(/^\/api\/orders\/([0-9a-f-]{36})\/receipt\.pdf$/i); if (receiptMatch && req.method === "GET") return downloadPaymentReceipt(req, res, receiptMatch[1]); const activationMatch = url.pathname.match(/^\/api\/orders\/([0-9a-f-]{36})\/activation-code$/i); if (activationMatch && req.method === "POST") return await issueActivationCode(req, res, activationMatch[1]); if (activationMatch && req.method === "DELETE") return await revokeActivationCode(req, res, activationMatch[1]); if (url.pathname === "/api/checkout" && req.method === "POST") return await startCheckout(req, res); if (["GET", "HEAD"].includes(req.method)) return await staticFile(req, res, url.pathname); return json(res, 404, { error: "Nicht gefunden." }); } catch (error) { const responseStatus = Number(error.status) >= 400 && Number(error.status) <= 599 ? Number(error.status) : 500; const knownMessages = { invalid_json: "Die Anfrage enthält ungültige Daten.", invalid_quantity: "Die gewählte Menge ist ungültig.", invalid_manager: "Die Manager-Variante ist ungültig.", too_large: "Die Anfrage ist zu groß." }; const message = error.publicMessage || knownMessages[error.message] || (responseStatus < 500 ? "Ungültige Anfrage." : "Die Anfrage konnte nicht verarbeitet werden."); if (responseStatus >= 500) console.error("request_failed", error.message); if (!res.headersSent) return json(res, responseStatus, { error: message }); res.destroy(); } finally { console.log(JSON.stringify({ method: req.method, path: url.pathname, status, durationMs: Date.now() - started })); } }); server.listen(port, "0.0.0.0", () => { console.log(`Enelix EMS Portal hört auf Port ${port}`); });