Files
Enelix-EMS/services/license/changes/20261006-heat-pump-header/files/server.mjs
T
2026-10-06 14:35:17 +00:00

2977 lines
135 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#039;"
})[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: `<p>Guten Tag ${name},</p><p>bitte bestätigen Sie Ihre E-Mail-Adresse für das Enelix EMS Portal.</p><p><a href="${escapeHtml(link)}">E-Mail-Adresse bestätigen</a></p><p>Der Link ist 24 Stunden und nur einmal gültig.</p>`
});
}
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: `<p>Guten Tag ${name},</p><p>über diesen Link können Sie Ihr Passwort zurücksetzen.</p><p><a href="${escapeHtml(link)}">Neues Passwort festlegen</a></p><p>Der Link ist 30 Minuten und nur einmal gültig. Falls Sie ihn nicht angefordert haben, ignorieren Sie diese Nachricht.</p>`
});
}
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: `<p>Guten Tag,</p><p>Sie wurden zur Organisation <strong>${organization}</strong> im Enelix EMS Portal eingeladen.</p><p><a href="${escapeHtml(link)}">Einladung annehmen</a></p><p>Der Link ist sieben Tage und nur einmal gültig. Sie können ein bestehendes Konto verwenden oder zuerst ein persönliches Konto erstellen.</p>`
});
}
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) => `<ul>${faults.map((fault) =>
`<li><strong>[${escapeHtml(faultSeverityLabel(fault.severity))}] ${escapeHtml(fault.sourceName)}</strong><br>${escapeHtml(fault.message)} <small>(${escapeHtml(fault.code)})</small></li>`
).join("")}</ul>`;
const textParts = [`Störungsänderung für ${plantName}.`];
const htmlParts = [`<p>Störungsänderung für <strong>${escapeHtml(plantName)}</strong>.</p>`];
if (opened.length) {
textParts.push(`Neu aufgetreten:\n${textRows(opened)}`);
htmlParts.push("<h2>Neu aufgetreten</h2>", htmlRows(opened));
}
if (resolved.length) {
textParts.push(`Behoben:\n${textRows(resolved)}`);
htmlParts.push("<h2>Behoben</h2>", htmlRows(resolved));
}
textParts.push(`Aktuell aktive Störungen: ${Number(payload.activeCount || 0)}`, `Portal: ${portalLink}`);
htmlParts.push(
`<p>Aktuell aktive Störungen: <strong>${Number(payload.activeCount || 0)}</strong></p>`,
`<p><a href="${escapeHtml(portalLink)}">Störüberwachung öffnen</a></p>`
);
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: `<p>Guten Tag ${escapeHtml(session.display_name)},</p><p>im Anhang erhalten Sie das lizenzierte Einrichtungspaket für <strong>${escapeHtml(configuration.name)}</strong>. Bewahren Sie diese Datei als Sicherung Ihrer Konfiguration auf.</p><p>Das Paket enthält ausschließlich die durch bezahlte Lizenzen freigegebenen Module.</p>`,
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: `<p>Guten Tag ${escapeHtml(session.display_name)},</p><p>im Anhang erhalten Sie ${replacement ? "den neuen Ersatzcode" : "den Aktivierungscode"} für <strong>${escapeHtml(plant?.name || "Ihr Enelix System")}</strong>.</p><p>${escapeHtml(replacementNotice)}</p><p>Bewahren Sie das Dokument sicher auf.</p>`,
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: `<p>Guten Tag ${escapeHtml(user.display_name)},</p><p>die benötigten Lizenzen für <strong>${escapeHtml(configuration.name)}</strong> sind jetzt bezahlt. Im Anhang erhalten Sie das freigegebene Einrichtungspaket.</p><p>Das Paket enthält ausschließlich die lizenzierten Module.</p>`,
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}`);
});