719 lines
23 KiB
JavaScript
719 lines
23 KiB
JavaScript
import bcrypt from "bcryptjs";
|
|
import cors from "cors";
|
|
import express from "express";
|
|
import { authenticateToken, createToken, requireRoles } from "./auth.js";
|
|
import { config } from "./config.js";
|
|
import { pool } from "./db.js";
|
|
import {
|
|
createWideTrendTable,
|
|
ensureIspMetadata,
|
|
fetchLatestValues,
|
|
fetchTrendSeries,
|
|
getAvailableIsps,
|
|
normalizeEngineeringUnit,
|
|
normalizeRange,
|
|
sanitizeIspName,
|
|
sanitizePointIndex,
|
|
updateIspMetadata,
|
|
} from "./lib/isp.js";
|
|
import {
|
|
deleteSelection,
|
|
getDashboard,
|
|
getPreferences,
|
|
getRoleMap,
|
|
getSelection,
|
|
listRoles,
|
|
listSelections,
|
|
saveDashboard,
|
|
savePreferences,
|
|
saveRole,
|
|
saveSelection,
|
|
} from "./lib/store.js";
|
|
import { redis } from "./redis.js";
|
|
|
|
const app = express();
|
|
const wrap = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
|
|
|
const allowedOrigins = config.corsOrigin
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
app.use(
|
|
cors({
|
|
origin(origin, callback) {
|
|
if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) {
|
|
callback(null, true);
|
|
return;
|
|
}
|
|
|
|
callback(new Error("Origin not allowed."));
|
|
},
|
|
})
|
|
);
|
|
app.use(express.json({ limit: "50mb" }));
|
|
|
|
function serializeUser(row) {
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
name: row.name,
|
|
email: row.email,
|
|
role: row.role,
|
|
active: Boolean(row.active),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function assertPoints(points) {
|
|
if (!Array.isArray(points) || points.length === 0) {
|
|
throw new Error("At least one data point is required.");
|
|
}
|
|
|
|
return points.map((point) => ({
|
|
isp: sanitizeIspName(point.isp),
|
|
pointIndex: sanitizePointIndex(point.pointIndex),
|
|
}));
|
|
}
|
|
|
|
function canUseSelection(selection, user) {
|
|
return Boolean(selection) && (selection.shared || selection.ownerId === user.sub || user.role === "admin");
|
|
}
|
|
|
|
function slugifyUsername(value) {
|
|
const base = String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 40);
|
|
|
|
return base || "user";
|
|
}
|
|
|
|
function nextAvailableUsername(seed, taken) {
|
|
const base = slugifyUsername(seed);
|
|
let candidate = base;
|
|
let counter = 1;
|
|
|
|
while (taken.has(candidate)) {
|
|
candidate = `${base}-${counter}`;
|
|
counter += 1;
|
|
}
|
|
|
|
taken.add(candidate);
|
|
return candidate;
|
|
}
|
|
|
|
function normalizeSqlDump(sql) {
|
|
return String(sql || "")
|
|
.replace(/^\uFEFF/, "")
|
|
.split(/\r?\n/)
|
|
.filter((line) => !line.trim().toUpperCase().startsWith("DELIMITER "))
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
const PERMISSION_CATALOG = [
|
|
{ id: "view_dashboard", label: "Dashboard anzeigen" },
|
|
{ id: "view_trends", label: "Trendansicht anzeigen" },
|
|
{ id: "edit_aliases", label: "Aliase bearbeiten" },
|
|
{ id: "manage_sources", label: "Quellen verwalten" },
|
|
{ id: "manage_users", label: "Benutzer verwalten" },
|
|
{ id: "manage_roles", label: "Rollen verwalten" },
|
|
{ id: "view_debug", label: "Treiber-Debug anzeigen" },
|
|
];
|
|
|
|
async function getUserPermissions(roleName) {
|
|
const roleMap = await getRoleMap(redis);
|
|
return roleMap.get(String(roleName || "").trim())?.permissions || [];
|
|
}
|
|
|
|
async function serializeUserWithPermissions(row) {
|
|
const user = serializeUser(row);
|
|
return {
|
|
...user,
|
|
permissions: await getUserPermissions(user.role),
|
|
};
|
|
}
|
|
|
|
function requirePermission(...permissions) {
|
|
return async (req, res, next) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: "Authentication required." });
|
|
}
|
|
|
|
const granted = new Set(await getUserPermissions(req.user.role));
|
|
if (!permissions.some((permission) => granted.has(permission))) {
|
|
return res.status(403).json({ error: "Insufficient permissions." });
|
|
}
|
|
|
|
next();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeRoleName(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 40) || "viewer";
|
|
}
|
|
|
|
async function resolveExistingRole(roleName, fallback = "viewer") {
|
|
const roleMap = await getRoleMap(redis);
|
|
const normalized = normalizeRoleName(roleName);
|
|
return roleMap.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
async function ensureRolesSchema() {
|
|
await listRoles(redis);
|
|
try {
|
|
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
|
|
} catch {}
|
|
}
|
|
|
|
async function resolveSelectionPoints(body, user) {
|
|
if (body.selectionId) {
|
|
const selection = await getSelection(redis, body.selectionId);
|
|
|
|
if (!canUseSelection(selection, user)) {
|
|
throw new Error("Selection not found or not accessible.");
|
|
}
|
|
|
|
return assertPoints(selection.points || []);
|
|
}
|
|
|
|
return assertPoints(body.points || []);
|
|
}
|
|
|
|
async function ensureUtf8Database() {
|
|
await pool.query(`ALTER DATABASE \`${config.dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
|
|
const [tables] = await pool.query(
|
|
`SELECT TABLE_NAME AS tableName
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = ?
|
|
AND (TABLE_NAME = 'app_users' OR TABLE_NAME LIKE 'isp%' OR TABLE_NAME LIKE 'trend_%')`,
|
|
[config.dbName]
|
|
);
|
|
|
|
for (const table of tables) {
|
|
await pool.query(`ALTER TABLE \`${table.tableName}\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
}
|
|
}
|
|
|
|
async function ensureUserSchema() {
|
|
await pool.query(
|
|
`
|
|
CREATE TABLE IF NOT EXISTS app_users (
|
|
id INT NOT NULL AUTO_INCREMENT,
|
|
username VARCHAR(80) NOT NULL,
|
|
name VARCHAR(120) NOT NULL,
|
|
email VARCHAR(160) NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
role VARCHAR(80) NOT NULL DEFAULT 'viewer',
|
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uniq_app_users_username (username),
|
|
UNIQUE KEY uniq_app_users_email (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
`
|
|
);
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users ADD COLUMN username VARCHAR(80) NULL AFTER id");
|
|
} catch {}
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users MODIFY email VARCHAR(160) NULL");
|
|
} catch {}
|
|
|
|
const [users] = await pool.query("SELECT id, username, name, email FROM app_users ORDER BY id");
|
|
const taken = new Set(users.map((user) => user.username).filter(Boolean));
|
|
|
|
for (const user of users) {
|
|
if (user.username) {
|
|
continue;
|
|
}
|
|
|
|
const seed = user.email?.split("@")[0] || user.name || `user-${user.id}`;
|
|
const username = nextAvailableUsername(seed, taken);
|
|
await pool.query("UPDATE app_users SET username = ? WHERE id = ?", [username, user.id]);
|
|
}
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users MODIFY username VARCHAR(80) NOT NULL");
|
|
} catch {}
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_username (username)");
|
|
} catch {}
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)");
|
|
} catch {}
|
|
|
|
try {
|
|
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
|
|
} catch {}
|
|
}
|
|
|
|
async function ensureAdminUser() {
|
|
const [rows] = await pool.query(
|
|
"SELECT * FROM app_users WHERE username = ? OR email = ? LIMIT 1",
|
|
[config.adminUsername.toLowerCase(), config.adminEmail.toLowerCase()]
|
|
);
|
|
|
|
if (rows[0]) {
|
|
const user = rows[0];
|
|
await pool.query(
|
|
`
|
|
UPDATE app_users
|
|
SET username = ?, name = ?, email = COALESCE(email, ?), role = 'admin', active = 1
|
|
WHERE id = ?
|
|
`,
|
|
[config.adminUsername.toLowerCase(), config.adminName, config.adminEmail.toLowerCase(), user.id]
|
|
);
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(config.adminPassword, 10);
|
|
|
|
await pool.query(
|
|
`
|
|
INSERT INTO app_users (username, name, email, password_hash, role, active)
|
|
VALUES (?, ?, ?, ?, 'admin', 1)
|
|
`,
|
|
[config.adminUsername.toLowerCase(), config.adminName, config.adminEmail.toLowerCase(), passwordHash]
|
|
);
|
|
}
|
|
|
|
app.get("/api/health", wrap(async (_req, res) => {
|
|
const [dbRows] = await pool.query("SELECT 1 AS ok");
|
|
const redisOk = await redis.ping();
|
|
res.json({
|
|
ok: dbRows[0]?.ok === 1 && redisOk === "PONG",
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}));
|
|
|
|
app.post("/api/auth/login", wrap(async (req, res) => {
|
|
const username = String(req.body.username || "").trim().toLowerCase();
|
|
const password = String(req.body.password || "");
|
|
|
|
const [rows] = await pool.query(
|
|
"SELECT * FROM app_users WHERE (username = ? OR email = ?) AND active = 1 LIMIT 1",
|
|
[username, username]
|
|
);
|
|
|
|
const user = rows[0];
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
|
|
}
|
|
|
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
|
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
|
|
}
|
|
|
|
const serializedUser = await serializeUserWithPermissions(user);
|
|
|
|
return res.json({
|
|
token: createToken(serializedUser),
|
|
user: serializedUser,
|
|
});
|
|
}));
|
|
|
|
app.post("/api/auth/change-password", authenticateToken, wrap(async (req, res) => {
|
|
const currentPassword = String(req.body.currentPassword || "");
|
|
const nextPassword = String(req.body.nextPassword || "");
|
|
|
|
if (nextPassword.length < 8) {
|
|
return res.status(400).json({ error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." });
|
|
}
|
|
|
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]);
|
|
const user = rows[0];
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
|
}
|
|
|
|
const isValid = await bcrypt.compare(currentPassword, user.password_hash);
|
|
|
|
if (!isValid) {
|
|
return res.status(400).json({ error: "Das aktuelle Passwort stimmt nicht." });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(nextPassword, 10);
|
|
await pool.query("UPDATE app_users SET password_hash = ? WHERE id = ?", [passwordHash, user.id]);
|
|
res.json({ ok: true });
|
|
}));
|
|
|
|
app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => {
|
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]);
|
|
|
|
if (!rows[0]) {
|
|
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
|
}
|
|
|
|
return res.json({ user: await serializeUserWithPermissions(rows[0]) });
|
|
}));
|
|
|
|
app.get("/api/isps", authenticateToken, wrap(async (_req, res) => {
|
|
const isps = await getAvailableIsps(pool, config.dbName);
|
|
const response = await Promise.all(
|
|
isps.map(async (isp) => {
|
|
const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount);
|
|
return {
|
|
...isp,
|
|
displayName: metadata.displayName,
|
|
description: metadata.description,
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json({ isps: response });
|
|
}));
|
|
|
|
app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => {
|
|
const metadata = await ensureIspMetadata(redis, req.params.isp);
|
|
res.json(metadata);
|
|
}));
|
|
|
|
app.put(
|
|
"/api/isps/:isp/aliases",
|
|
authenticateToken,
|
|
requirePermission("edit_aliases"),
|
|
wrap(async (req, res) => {
|
|
const { displayName, description, points } = req.body || {};
|
|
const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => {
|
|
if (typeof displayName === "string") {
|
|
metadata.displayName = displayName.trim() || metadata.displayName;
|
|
}
|
|
|
|
if (typeof description === "string") {
|
|
metadata.description = description.trim();
|
|
}
|
|
|
|
if (Array.isArray(points)) {
|
|
points.forEach((point) => {
|
|
const pointIndex = sanitizePointIndex(point.pointIndex);
|
|
const key = String(pointIndex);
|
|
metadata.points[key] = {
|
|
...metadata.points[key],
|
|
alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias,
|
|
unit: normalizeEngineeringUnit(point.unit || metadata.points[key].unit),
|
|
factor: Number.isFinite(Number(point.factor)) ? Number(point.factor) : metadata.points[key].factor,
|
|
kind: point.kind === "digital" ? "digital" : "analog",
|
|
min: Number.isFinite(Number(point.min)) ? Number(point.min) : metadata.points[key].min,
|
|
max: Number.isFinite(Number(point.max)) ? Number(point.max) : metadata.points[key].max,
|
|
color: typeof point.color === "string" ? point.color : metadata.points[key].color,
|
|
};
|
|
});
|
|
}
|
|
|
|
return metadata;
|
|
});
|
|
|
|
res.json(updated);
|
|
})
|
|
);
|
|
|
|
app.post("/api/trend-tables", authenticateToken, requirePermission("manage_sources"), wrap(async (req, res) => {
|
|
const rawName = String(req.body.tableName || "").trim().toLowerCase();
|
|
const displayName = String(req.body.displayName || rawName).trim();
|
|
const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`);
|
|
const metadata = await updateIspMetadata(redis, safeName, (current) => ({
|
|
...current,
|
|
displayName: displayName || current.displayName,
|
|
}));
|
|
res.status(201).json({ tableName: safeName, metadata });
|
|
}));
|
|
|
|
app.post(
|
|
"/api/admin/normalize-alias-metadata",
|
|
authenticateToken,
|
|
requirePermission("edit_aliases"),
|
|
wrap(async (req, res) => {
|
|
const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null;
|
|
|
|
if (!targetIsp && req.user.role !== "admin") {
|
|
return res.status(403).json({ error: "Nur Admins dürfen alle ISPs auf einmal bereinigen." });
|
|
}
|
|
|
|
const targets = targetIsp
|
|
? [{ tableName: targetIsp, pointCount: 200 }]
|
|
: await getAvailableIsps(pool, config.dbName);
|
|
|
|
const normalized = [];
|
|
for (const isp of targets) {
|
|
const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount || 200);
|
|
normalized.push({ isp: isp.tableName, metadata });
|
|
}
|
|
|
|
res.json({ ok: true, normalizedCount: normalized.length, items: normalized });
|
|
})
|
|
);
|
|
|
|
app.post(
|
|
"/api/admin/import-sql",
|
|
authenticateToken,
|
|
requirePermission("manage_sources"),
|
|
wrap(async (req, res) => {
|
|
const sql = normalizeSqlDump(req.body.sql);
|
|
|
|
if (!sql) {
|
|
throw new Error("SQL-Inhalt ist leer.");
|
|
}
|
|
|
|
await pool.query(sql);
|
|
const isps = await getAvailableIsps(pool, config.dbName);
|
|
res.json({ ok: true, importedTables: isps.length, filename: req.body.filename || null });
|
|
})
|
|
);
|
|
|
|
app.get("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (_req, res) => {
|
|
const [rows] = await pool.query(
|
|
"SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username"
|
|
);
|
|
res.json({ users: rows.map(serializeUser) });
|
|
}));
|
|
|
|
app.post("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
|
|
const username = String(req.body.username || "").trim().toLowerCase();
|
|
const name = String(req.body.name || "").trim();
|
|
const email = String(req.body.email || "").trim().toLowerCase() || null;
|
|
const password = String(req.body.password || "");
|
|
const role = await resolveExistingRole(req.body.role, "viewer");
|
|
|
|
if (!username || !name) {
|
|
return res.status(400).json({ error: "Username und Name sind erforderlich." });
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ error: "Das Passwort muss mindestens 8 Zeichen lang sein." });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
|
|
await pool.query(
|
|
`
|
|
INSERT INTO app_users (username, name, email, password_hash, role, active)
|
|
VALUES (?, ?, ?, ?, ?, 1)
|
|
`,
|
|
[username, name, email, passwordHash, role]
|
|
);
|
|
|
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE username = ? LIMIT 1", [username]);
|
|
res.status(201).json({ user: serializeUser(rows[0]) });
|
|
}));
|
|
|
|
app.patch("/api/users/:id", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
|
|
const userId = Number(req.params.id);
|
|
const payload = req.body || {};
|
|
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
|
|
|
|
if (!rows[0]) {
|
|
return res.status(404).json({ error: "Benutzer nicht gefunden." });
|
|
}
|
|
|
|
const current = rows[0];
|
|
const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username;
|
|
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
|
|
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email;
|
|
const nextRole = payload.role ? await resolveExistingRole(payload.role, current.role) : current.role;
|
|
if (userId === req.user.sub && payload.role && nextRole !== current.role) {
|
|
return res.status(403).json({ error: "Die eigene Rolle kann nicht geändert werden." });
|
|
}
|
|
|
|
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
|
|
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
|
|
|
|
await pool.query(
|
|
`
|
|
UPDATE app_users
|
|
SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ?
|
|
WHERE id = ?
|
|
`,
|
|
[username, name, email, nextRole, active, passwordHash, userId]
|
|
);
|
|
|
|
const [updatedRows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
|
|
res.json({ user: serializeUser(updatedRows[0]) });
|
|
}));
|
|
|
|
app.get("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (_req, res) => {
|
|
const roles = await listRoles(redis);
|
|
res.json({ roles, permissions: PERMISSION_CATALOG });
|
|
}));
|
|
|
|
app.post("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
|
|
const payload = req.body || {};
|
|
const name = normalizeRoleName(payload.name);
|
|
if (!name) {
|
|
return res.status(400).json({ error: "Rollenname fehlt." });
|
|
}
|
|
|
|
const role = await saveRole(redis, {
|
|
name,
|
|
label: String(payload.label || name).trim() || name,
|
|
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],
|
|
});
|
|
res.status(201).json({ role });
|
|
}));
|
|
|
|
app.patch("/api/roles/:name", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
|
|
const roleMap = await getRoleMap(redis);
|
|
const existing = roleMap.get(normalizeRoleName(req.params.name));
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "Rolle nicht gefunden." });
|
|
}
|
|
|
|
const role = await saveRole(redis, {
|
|
...existing,
|
|
label: String(req.body.label || existing.label).trim() || existing.label,
|
|
permissions: Array.isArray(req.body.permissions) ? req.body.permissions : existing.permissions,
|
|
}, existing.name);
|
|
res.json({ role });
|
|
}));
|
|
|
|
app.get("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const selections = await listSelections(redis, req.user);
|
|
res.json({ selections });
|
|
}));
|
|
|
|
app.post("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const points = assertPoints(req.body.points || []);
|
|
const selection = await saveSelection(redis, {
|
|
ownerId: req.user.sub,
|
|
ownerName: req.user.name,
|
|
name: String(req.body.name || "").trim() || "Neue Auswahl",
|
|
description: String(req.body.description || "").trim(),
|
|
shared: Boolean(req.body.shared),
|
|
points,
|
|
});
|
|
|
|
res.status(201).json({ selection });
|
|
}));
|
|
|
|
app.put("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const existing = await getSelection(redis, req.params.selectionId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "Auswahl nicht gefunden." });
|
|
}
|
|
|
|
if (existing.ownerId !== req.user.sub && req.user.role !== "admin") {
|
|
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen bearbeiten." });
|
|
}
|
|
|
|
const points = assertPoints(req.body.points || existing.points || []);
|
|
const selection = await saveSelection(
|
|
redis,
|
|
{
|
|
...existing,
|
|
name: String(req.body.name || existing.name).trim(),
|
|
description: String(req.body.description || existing.description || "").trim(),
|
|
shared: typeof req.body.shared === "boolean" ? req.body.shared : existing.shared,
|
|
points,
|
|
},
|
|
existing.id
|
|
);
|
|
|
|
res.json({ selection });
|
|
}));
|
|
|
|
app.delete("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const existing = await getSelection(redis, req.params.selectionId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "Auswahl nicht gefunden." });
|
|
}
|
|
|
|
if (existing.ownerId !== req.user.sub && req.user.role !== "admin") {
|
|
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen löschen." });
|
|
}
|
|
|
|
await deleteSelection(redis, existing.id);
|
|
res.status(204).send();
|
|
}));
|
|
|
|
app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const points = await resolveSelectionPoints(req.body, req.user);
|
|
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range));
|
|
res.json(trendData);
|
|
}));
|
|
|
|
app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
|
|
const points = await resolveSelectionPoints(req.body, req.user);
|
|
const values = await fetchLatestValues(pool, redis, points);
|
|
res.json({ values });
|
|
}));
|
|
|
|
app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
|
|
const dashboard = await getDashboard(redis, req.user.sub);
|
|
res.json(dashboard);
|
|
}));
|
|
|
|
app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
|
|
const dashboard = await saveDashboard(redis, req.user.sub, req.body || {});
|
|
res.json(dashboard);
|
|
}));
|
|
|
|
app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
|
|
const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : [];
|
|
const results = [];
|
|
|
|
for (const widget of widgets) {
|
|
const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []);
|
|
|
|
if (widget.type === "chart") {
|
|
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range));
|
|
results.push({ id: widget.id, type: widget.type, ...trendData });
|
|
} else {
|
|
const values = await fetchLatestValues(pool, redis, points);
|
|
results.push({ id: widget.id, type: widget.type, values });
|
|
}
|
|
}
|
|
|
|
res.json({ widgets: results });
|
|
}));
|
|
|
|
app.get("/api/preferences", authenticateToken, wrap(async (req, res) => {
|
|
const preferences = await getPreferences(redis, req.user.sub);
|
|
res.json(preferences);
|
|
}));
|
|
|
|
app.put("/api/preferences", authenticateToken, wrap(async (req, res) => {
|
|
const preferences = await savePreferences(redis, req.user.sub, req.body || {});
|
|
res.json(preferences);
|
|
}));
|
|
|
|
app.use((error, _req, res, _next) => {
|
|
console.error(error);
|
|
res.status(400).json({ error: error.message || "Unexpected error." });
|
|
});
|
|
|
|
await ensureUtf8Database();
|
|
await ensureUserSchema();
|
|
await ensureRolesSchema();
|
|
await ensureAdminUser();
|
|
|
|
app.listen(config.port, () => {
|
|
console.log(`SE Local Trenddata API listening on port ${config.port}`);
|
|
});
|
|
|
|
|
|
|