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