945 lines
29 KiB
JavaScript
945 lines
29 KiB
JavaScript
const express = require("express");
|
|
const bcrypt = require("bcrypt");
|
|
const jwt = require("jsonwebtoken");
|
|
const multer = require("multer");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { Pool } = require("pg");
|
|
|
|
const app = express();
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const JWT_SECRET = process.env.JWT_SECRET || "elevator-manager-secret";
|
|
const DATABASE_URL = process.env.DATABASE_URL || "postgresql://lift_admin:lift_secret@127.0.0.1:5432/lift_manager";
|
|
const SALT_ROUNDS = 10;
|
|
const MAX_FILES = 8;
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
const COOKIE_NAME = "liftToken";
|
|
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
const DATA_DIR = path.join(__dirname, "data");
|
|
const UPLOAD_DIR = path.join(__dirname, "uploads");
|
|
const LEGACY_DATA_FILE = path.join(DATA_DIR, "store.json");
|
|
|
|
const pool = new Pool({
|
|
connectionString: DATABASE_URL
|
|
});
|
|
|
|
ensureDirectories();
|
|
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(express.static(PUBLIC_DIR));
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
|
filename: (_req, file, cb) => {
|
|
const extension = path.extname(file.originalname).toLowerCase();
|
|
cb(null, `${Date.now()}-${crypto.randomUUID()}${extension}`);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: {
|
|
fileSize: MAX_FILE_SIZE,
|
|
files: MAX_FILES
|
|
},
|
|
fileFilter: (_req, file, cb) => {
|
|
const isPdf = file.mimetype === "application/pdf" || path.extname(file.originalname).toLowerCase() === ".pdf";
|
|
cb(isPdf ? null : new Error("Csak PDF fájl tölthető fel."), isPdf);
|
|
}
|
|
});
|
|
|
|
app.get("/", (_req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
|
|
});
|
|
|
|
app.get("/bejelentkezes", requireGuestPage, (_req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, "login.html"));
|
|
});
|
|
|
|
app.get("/regisztracio", requireGuestPage, (_req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, "register.html"));
|
|
});
|
|
|
|
app.get("/alkalmazas", requirePageAuth, (_req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, "app.html"));
|
|
});
|
|
|
|
app.get("/naplo", requirePageAuth, (_req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, "logs.html"));
|
|
});
|
|
|
|
app.get("/api/health", async (_req, res) => {
|
|
const result = await pool.query("SELECT NOW() AS now");
|
|
res.json({ message: "A rendszer működik.", dbTime: result.rows[0].now });
|
|
});
|
|
|
|
app.post("/api/register", async (req, res) => {
|
|
try {
|
|
const username = req.body.username?.trim().toLowerCase();
|
|
const password = req.body.password?.trim();
|
|
const fullName = req.body.fullName?.trim();
|
|
const routeName = req.body.routeName?.trim();
|
|
|
|
if (!username || !password || !fullName || !routeName) {
|
|
return res.status(400).json({ message: "Minden regisztrációs mező kötelező." });
|
|
}
|
|
|
|
const existingUser = await pool.query("SELECT id FROM users WHERE username = $1", [username]);
|
|
|
|
if (existingUser.rowCount) {
|
|
return res.status(409).json({ message: "Ez a felhasználónév már foglalt." });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
|
|
|
await pool.query(
|
|
`INSERT INTO users (username, password_hash, full_name, route_name)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[username, passwordHash, fullName, routeName]
|
|
);
|
|
|
|
return res.status(201).json({
|
|
message: "Sikeres regisztráció. Most már bejelentkezhetsz.",
|
|
redirectPath: "/bejelentkezes"
|
|
});
|
|
} catch (_error) {
|
|
return res.status(500).json({ message: "Hiba történt a regisztráció során." });
|
|
}
|
|
});
|
|
|
|
app.post("/api/login", async (req, res) => {
|
|
try {
|
|
const username = req.body.username?.trim().toLowerCase();
|
|
const password = req.body.password?.trim();
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ message: "Felhasználónév és jelszó kötelező." });
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`SELECT id, username, password_hash, full_name, route_name
|
|
FROM users
|
|
WHERE username = $1`,
|
|
[username]
|
|
);
|
|
|
|
if (!result.rowCount) {
|
|
return res.status(401).json({ message: "Hibás bejelentkezési adatok." });
|
|
}
|
|
|
|
const user = mapUserRow(result.rows[0]);
|
|
const passwordMatches = await bcrypt.compare(password, result.rows[0].password_hash);
|
|
|
|
if (!passwordMatches) {
|
|
return res.status(401).json({ message: "Hibás bejelentkezési adatok." });
|
|
}
|
|
|
|
setAuthCookie(res, createToken(user));
|
|
|
|
return res.json({
|
|
message: "Sikeres bejelentkezés.",
|
|
redirectPath: "/alkalmazas",
|
|
user
|
|
});
|
|
} catch (_error) {
|
|
return res.status(500).json({ message: "Hiba történt a bejelentkezés során." });
|
|
}
|
|
});
|
|
|
|
app.post("/api/logout", (_req, res) => {
|
|
clearAuthCookie(res);
|
|
return res.json({ message: "Sikeres kijelentkezés.", redirectPath: "/bejelentkezes" });
|
|
});
|
|
|
|
app.get("/api/me", authenticateRequest, (req, res) => {
|
|
res.json({ user: req.user });
|
|
});
|
|
|
|
app.get("/api/dashboard", authenticateRequest, async (req, res) => {
|
|
const result = await pool.query(
|
|
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at
|
|
FROM elevators
|
|
WHERE route_name = $1
|
|
AND next_inspection_date IS NOT NULL
|
|
AND next_inspection_date >= CURRENT_DATE
|
|
AND next_inspection_date <= CURRENT_DATE + INTERVAL '7 days'
|
|
ORDER BY next_inspection_date ASC NULLS LAST, id DESC`,
|
|
[req.user.routeName]
|
|
);
|
|
|
|
res.json({
|
|
routeName: req.user.routeName,
|
|
elevators: result.rows.map(mapElevatorRow)
|
|
});
|
|
});
|
|
|
|
app.get("/api/elevators", authenticateRequest, async (req, res) => {
|
|
const result = await pool.query(
|
|
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at
|
|
FROM elevators
|
|
WHERE route_name = $1
|
|
ORDER BY name ASC, id DESC`,
|
|
[req.user.routeName]
|
|
);
|
|
|
|
res.json({
|
|
routeName: req.user.routeName,
|
|
elevators: result.rows.map(mapElevatorRow)
|
|
});
|
|
});
|
|
|
|
app.post("/api/elevators", authenticateRequest, async (req, res) => {
|
|
const payload = normalizeElevatorPayload(req.body, req.user);
|
|
const validationMessage = validateElevatorPayload(payload);
|
|
|
|
if (validationMessage) {
|
|
return res.status(400).json({ message: validationMessage });
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO elevators (
|
|
name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at`,
|
|
[
|
|
payload.name,
|
|
payload.serialNumber,
|
|
payload.location,
|
|
payload.routeName,
|
|
payload.lastInspectionDate,
|
|
payload.nextInspectionDate,
|
|
payload.inspectionIntervalDays,
|
|
payload.status
|
|
]
|
|
);
|
|
|
|
res.status(201).json({
|
|
message: "Az eszköz sikeresen létre lett hozva.",
|
|
elevator: mapElevatorRow(result.rows[0])
|
|
});
|
|
});
|
|
|
|
app.put("/api/elevators/:elevatorId", authenticateRequest, async (req, res) => {
|
|
const elevatorId = Number(req.params.elevatorId);
|
|
|
|
if (!Number.isInteger(elevatorId) || elevatorId <= 0) {
|
|
return res.status(400).json({ message: "Érvénytelen eszközazonosító." });
|
|
}
|
|
|
|
const payload = normalizeElevatorPayload(req.body, req.user);
|
|
const validationMessage = validateElevatorPayload(payload);
|
|
|
|
if (validationMessage) {
|
|
return res.status(400).json({ message: validationMessage });
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`UPDATE elevators
|
|
SET name = $1,
|
|
serial_number = $2,
|
|
location = $3,
|
|
route_name = $4,
|
|
last_inspection_date = $5,
|
|
next_inspection_date = $6,
|
|
inspection_interval_days = $7,
|
|
last_status = $8,
|
|
updated_at = NOW()
|
|
WHERE id = $9 AND route_name = $10
|
|
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at`,
|
|
[
|
|
payload.name,
|
|
payload.serialNumber,
|
|
payload.location,
|
|
payload.routeName,
|
|
payload.lastInspectionDate,
|
|
payload.nextInspectionDate,
|
|
payload.inspectionIntervalDays,
|
|
payload.status,
|
|
elevatorId,
|
|
req.user.routeName
|
|
]
|
|
);
|
|
|
|
if (!result.rowCount) {
|
|
return res.status(404).json({ message: "A keresett eszköz nem található." });
|
|
}
|
|
|
|
res.json({
|
|
message: "Az eszköz adatai frissültek.",
|
|
elevator: mapElevatorRow(result.rows[0])
|
|
});
|
|
});
|
|
|
|
app.get("/api/logs", authenticateRequest, async (req, res) => {
|
|
const search = req.query.search?.trim().toLowerCase() || "";
|
|
const sortBy = req.query.sortBy?.trim() || "inspectionDateDesc";
|
|
const params = [req.user.routeName];
|
|
let whereClause = "WHERE l.route_name = $1";
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
whereClause += ` AND LOWER(CONCAT_WS(' ', l.elevator_name, l.serial_number, l.location, l.route_name, l.technician_name, l.notes, l.status)) LIKE $2`;
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`SELECT l.id, l.elevator_id, l.elevator_name, l.serial_number, l.location, l.route_name, l.inspection_date,
|
|
l.next_inspection_date, l.inspection_interval_days, l.status, l.notes,
|
|
l.technician_name, l.created_by_user_id, l.created_at,
|
|
COALESCE(
|
|
JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'id', lf.id,
|
|
'originalName', lf.original_name,
|
|
'size', lf.size,
|
|
'downloadPath', '/api/logs/' || l.id || '/files/' || lf.id
|
|
)
|
|
ORDER BY lf.id
|
|
) FILTER (WHERE lf.id IS NOT NULL),
|
|
'[]'::json
|
|
) AS files
|
|
FROM logs l
|
|
LEFT JOIN log_files lf ON lf.log_id = l.id
|
|
${whereClause}
|
|
GROUP BY l.id
|
|
ORDER BY ${buildLogOrder(sortBy)}`,
|
|
params
|
|
);
|
|
|
|
res.json({ logs: result.rows.map(mapLogRow) });
|
|
});
|
|
|
|
app.post("/api/logs", authenticateRequest, upload.array("pdfs", MAX_FILES), async (req, res) => {
|
|
const uploadedFiles = req.files || [];
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
const payload = normalizeLogPayload(req.body, req.user);
|
|
const validationMessage = validateLogPayload(payload);
|
|
|
|
if (validationMessage) {
|
|
removeUploadedFiles(uploadedFiles);
|
|
return res.status(400).json({ message: validationMessage });
|
|
}
|
|
|
|
await client.query("BEGIN");
|
|
|
|
let elevatorRow = null;
|
|
|
|
if (payload.elevatorId) {
|
|
elevatorRow = await findElevatorById(client, payload.elevatorId, req.user.routeName);
|
|
|
|
if (!elevatorRow) {
|
|
removeUploadedFiles(uploadedFiles);
|
|
return res.status(404).json({ message: "A kiválasztott eszköz nem található." });
|
|
}
|
|
|
|
const updatedElevator = await client.query(
|
|
`UPDATE elevators
|
|
SET last_inspection_date = $1,
|
|
next_inspection_date = $2,
|
|
inspection_interval_days = $3,
|
|
last_status = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $5
|
|
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at`,
|
|
[
|
|
payload.inspectionDate,
|
|
payload.nextInspectionDate,
|
|
payload.inspectionIntervalDays,
|
|
payload.status,
|
|
payload.elevatorId
|
|
]
|
|
);
|
|
|
|
elevatorRow = updatedElevator.rows[0];
|
|
} else {
|
|
const upsertedElevator = await client.query(
|
|
`INSERT INTO elevators (
|
|
name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (serial_number)
|
|
DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
location = EXCLUDED.location,
|
|
route_name = EXCLUDED.route_name,
|
|
last_inspection_date = EXCLUDED.last_inspection_date,
|
|
next_inspection_date = EXCLUDED.next_inspection_date,
|
|
inspection_interval_days = EXCLUDED.inspection_interval_days,
|
|
last_status = EXCLUDED.last_status,
|
|
updated_at = NOW()
|
|
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at`,
|
|
[
|
|
payload.elevatorName,
|
|
payload.serialNumber,
|
|
payload.location,
|
|
payload.routeName,
|
|
payload.inspectionDate,
|
|
payload.nextInspectionDate,
|
|
payload.inspectionIntervalDays,
|
|
payload.status
|
|
]
|
|
);
|
|
|
|
elevatorRow = upsertedElevator.rows[0];
|
|
}
|
|
|
|
const logInsert = await client.query(
|
|
`INSERT INTO logs (
|
|
elevator_id, elevator_name, serial_number, location, route_name, inspection_date,
|
|
next_inspection_date, inspection_interval_days, status, notes,
|
|
technician_name, created_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
RETURNING id`,
|
|
[
|
|
elevatorRow.id,
|
|
elevatorRow.name,
|
|
elevatorRow.serial_number,
|
|
elevatorRow.location,
|
|
elevatorRow.route_name,
|
|
payload.inspectionDate,
|
|
payload.nextInspectionDate,
|
|
payload.inspectionIntervalDays,
|
|
payload.status,
|
|
payload.notes,
|
|
req.user.fullName,
|
|
req.user.id
|
|
]
|
|
);
|
|
|
|
const logId = logInsert.rows[0].id;
|
|
|
|
for (const file of uploadedFiles) {
|
|
const originalName = normalizeUploadedFilename(file.originalname);
|
|
|
|
await client.query(
|
|
`INSERT INTO log_files (log_id, original_name, stored_name, size, mime_type)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[logId, originalName, file.filename, file.size, file.mimetype]
|
|
);
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
|
|
res.status(201).json({
|
|
message: "Az ellenőrzési bejegyzés sikeresen mentve lett.",
|
|
log: await fetchLogById(logId)
|
|
});
|
|
} catch (_error) {
|
|
await client.query("ROLLBACK");
|
|
removeUploadedFiles(uploadedFiles);
|
|
res.status(500).json({ message: "Hiba történt a bejegyzés mentése közben." });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.get("/api/logs/:logId/files/:fileId", authenticateRequest, async (req, res) => {
|
|
const logId = Number(req.params.logId);
|
|
const fileId = Number(req.params.fileId);
|
|
const result = await pool.query(
|
|
`SELECT stored_name, original_name
|
|
FROM log_files
|
|
WHERE id = $1 AND log_id = $2`,
|
|
[fileId, logId]
|
|
);
|
|
|
|
if (!result.rowCount) {
|
|
return res.status(404).json({ message: "A keresett fájl nem található." });
|
|
}
|
|
|
|
const file = result.rows[0];
|
|
const absolutePath = path.join(UPLOAD_DIR, file.stored_name);
|
|
|
|
if (!fs.existsSync(absolutePath)) {
|
|
return res.status(404).json({ message: "A fájl már nem érhető el." });
|
|
}
|
|
|
|
return res.download(absolutePath, normalizeUploadedFilename(file.original_name));
|
|
});
|
|
|
|
app.use((error, req, res, _next) => {
|
|
removeUploadedFiles(req.files || []);
|
|
const isMulterError = error instanceof multer.MulterError;
|
|
const message = isMulterError
|
|
? "A PDF feltöltés sikertelen volt. Ellenőrizd a fájlméretet és a darabszámot."
|
|
: error.message || "Váratlan szerverhiba történt.";
|
|
|
|
res.status(400).json({ message });
|
|
});
|
|
|
|
startServer();
|
|
|
|
async function startServer() {
|
|
try {
|
|
await initializeDatabase();
|
|
await migrateLegacyJsonIfNeeded();
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`A liftellenőrzési kezelő fut a(z) http://localhost:${PORT} címen.`);
|
|
});
|
|
} catch (error) {
|
|
console.error("Nem sikerült elindítani a szervert:", error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function ensureDirectories() {
|
|
[DATA_DIR, UPLOAD_DIR, PUBLIC_DIR].forEach((directory) => {
|
|
if (!fs.existsSync(directory)) {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initializeDatabase() {
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
full_name TEXT NOT NULL,
|
|
route_name TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS elevators (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
serial_number TEXT NOT NULL UNIQUE,
|
|
location TEXT NOT NULL,
|
|
route_name TEXT NOT NULL,
|
|
last_inspection_date DATE,
|
|
next_inspection_date DATE,
|
|
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
|
|
last_status TEXT NOT NULL DEFAULT 'rendben',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS logs (
|
|
id SERIAL PRIMARY KEY,
|
|
elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL,
|
|
elevator_name TEXT NOT NULL,
|
|
serial_number TEXT NOT NULL,
|
|
location TEXT NOT NULL,
|
|
route_name TEXT NOT NULL,
|
|
inspection_date DATE NOT NULL,
|
|
next_inspection_date DATE NOT NULL,
|
|
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
|
|
status TEXT NOT NULL,
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
technician_name TEXT NOT NULL,
|
|
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS log_files (
|
|
id SERIAL PRIMARY KEY,
|
|
log_id INTEGER NOT NULL REFERENCES logs(id) ON DELETE CASCADE,
|
|
original_name TEXT NOT NULL,
|
|
stored_name TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
mime_type TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
await pool.query(`
|
|
ALTER TABLE logs
|
|
ADD COLUMN IF NOT EXISTS elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL;
|
|
`);
|
|
}
|
|
|
|
async function migrateLegacyJsonIfNeeded() {
|
|
if (!fs.existsSync(LEGACY_DATA_FILE)) {
|
|
return;
|
|
}
|
|
|
|
const userCount = await pool.query("SELECT COUNT(*)::int AS count FROM users");
|
|
|
|
if (userCount.rows[0].count > 0) {
|
|
return;
|
|
}
|
|
|
|
const legacy = JSON.parse(fs.readFileSync(LEGACY_DATA_FILE, "utf-8"));
|
|
const client = await pool.connect();
|
|
const userIdMap = new Map();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
for (const user of legacy.users || []) {
|
|
const result = await client.query(
|
|
`INSERT INTO users (username, password_hash, full_name, route_name, created_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id`,
|
|
[user.username, user.passwordHash, user.fullName, user.routeName, user.createdAt || new Date().toISOString()]
|
|
);
|
|
userIdMap.set(user.id, result.rows[0].id);
|
|
}
|
|
|
|
for (const elevator of legacy.elevators || []) {
|
|
await client.query(
|
|
`INSERT INTO elevators (
|
|
name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
[
|
|
elevator.name,
|
|
elevator.serialNumber,
|
|
elevator.location,
|
|
elevator.routeName,
|
|
elevator.lastInspectionDate || null,
|
|
elevator.nextInspectionDate || null,
|
|
elevator.inspectionIntervalDays || 30,
|
|
elevator.lastStatus || "rendben",
|
|
elevator.updatedAt || new Date().toISOString()
|
|
]
|
|
);
|
|
}
|
|
|
|
for (const log of legacy.logs || []) {
|
|
const logResult = await client.query(
|
|
`INSERT INTO logs (
|
|
elevator_name, serial_number, location, route_name, inspection_date,
|
|
next_inspection_date, inspection_interval_days, status, notes,
|
|
technician_name, created_by_user_id, created_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
RETURNING id`,
|
|
[
|
|
log.elevatorName,
|
|
log.serialNumber,
|
|
log.location,
|
|
log.routeName,
|
|
log.inspectionDate,
|
|
log.nextInspectionDate,
|
|
log.inspectionIntervalDays || 30,
|
|
log.status || "rendben",
|
|
log.notes || "",
|
|
log.technicianName,
|
|
userIdMap.get(log.createdByUserId) || null,
|
|
log.createdAt || new Date().toISOString()
|
|
]
|
|
);
|
|
|
|
for (const file of log.files || []) {
|
|
await client.query(
|
|
`INSERT INTO log_files (log_id, original_name, stored_name, size, mime_type)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[logResult.rows[0].id, file.originalName, file.storedName, file.size || 0, file.mimeType || "application/pdf"]
|
|
);
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
function createToken(user) {
|
|
return jwt.sign(user, JWT_SECRET, { expiresIn: "8h" });
|
|
}
|
|
|
|
function setAuthCookie(res, token) {
|
|
res.setHeader("Set-Cookie", serializeCookie(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
maxAge: 8 * 60 * 60
|
|
}));
|
|
}
|
|
|
|
function clearAuthCookie(res) {
|
|
res.setHeader("Set-Cookie", serializeCookie(COOKIE_NAME, "", {
|
|
httpOnly: true,
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
maxAge: 0
|
|
}));
|
|
}
|
|
|
|
function serializeCookie(name, value, options) {
|
|
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
parts.push(`Path=${options.path || "/"}`);
|
|
parts.push(`Max-Age=${options.maxAge || 0}`);
|
|
parts.push("HttpOnly");
|
|
parts.push(`SameSite=${options.sameSite || "Lax"}`);
|
|
return parts.join("; ");
|
|
}
|
|
|
|
function parseCookies(req) {
|
|
const rawCookie = req.headers.cookie || "";
|
|
|
|
return rawCookie.split(";").reduce((cookies, entry) => {
|
|
const [name, ...rest] = entry.trim().split("=");
|
|
|
|
if (!name) {
|
|
return cookies;
|
|
}
|
|
|
|
cookies[name] = decodeURIComponent(rest.join("="));
|
|
return cookies;
|
|
}, {});
|
|
}
|
|
|
|
function getCookieToken(req) {
|
|
return parseCookies(req)[COOKIE_NAME] || "";
|
|
}
|
|
|
|
function getTokenFromRequest(req) {
|
|
const headerToken = req.headers.authorization?.startsWith("Bearer ")
|
|
? req.headers.authorization.slice(7)
|
|
: "";
|
|
const queryToken = req.query.token?.trim() || "";
|
|
const cookieToken = getCookieToken(req);
|
|
|
|
return headerToken || cookieToken || queryToken;
|
|
}
|
|
|
|
function verifyToken(token) {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET);
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function requireGuestPage(req, res, next) {
|
|
const token = getCookieToken(req);
|
|
return token && verifyToken(token) ? res.redirect("/alkalmazas") : next();
|
|
}
|
|
|
|
function requirePageAuth(req, res, next) {
|
|
const token = getCookieToken(req);
|
|
const decoded = token ? verifyToken(token) : null;
|
|
|
|
if (!decoded) {
|
|
return res.redirect("/bejelentkezes");
|
|
}
|
|
|
|
req.user = decoded;
|
|
next();
|
|
}
|
|
|
|
function authenticateRequest(req, res, next) {
|
|
const token = getTokenFromRequest(req);
|
|
const decoded = token ? verifyToken(token) : null;
|
|
|
|
if (!decoded) {
|
|
return res.status(401).json({ message: "Bejelentkezés szükséges." });
|
|
}
|
|
|
|
req.user = decoded;
|
|
next();
|
|
}
|
|
|
|
function normalizeElevatorPayload(body, user) {
|
|
const inspectionIntervalDays = Number(body.inspectionIntervalDays || 30);
|
|
|
|
return {
|
|
name: body.name?.trim(),
|
|
serialNumber: body.serialNumber?.trim().toUpperCase(),
|
|
location: body.location?.trim(),
|
|
routeName: user.routeName,
|
|
lastInspectionDate: body.lastInspectionDate?.trim() || null,
|
|
nextInspectionDate: body.nextInspectionDate?.trim() || null,
|
|
inspectionIntervalDays: Number.isFinite(inspectionIntervalDays) ? inspectionIntervalDays : 30,
|
|
status: body.status?.trim() || "rendben"
|
|
};
|
|
}
|
|
|
|
function validateElevatorPayload(payload) {
|
|
if (!payload.name || !payload.serialNumber || !payload.location) {
|
|
return "Az eszköz neve, gyári száma és helyszíne kötelező.";
|
|
}
|
|
|
|
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
|
|
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
|
|
}
|
|
|
|
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
|
|
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function normalizeLogPayload(body, user) {
|
|
const inspectionIntervalDays = Number(body.inspectionIntervalDays || 30);
|
|
|
|
return {
|
|
elevatorId: Number(body.elevatorId || 0) || null,
|
|
elevatorName: body.elevatorName?.trim(),
|
|
serialNumber: body.serialNumber?.trim().toUpperCase(),
|
|
location: body.location?.trim(),
|
|
routeName: body.routeName?.trim() || user.routeName,
|
|
inspectionDate: body.inspectionDate?.trim(),
|
|
nextInspectionDate: body.nextInspectionDate?.trim(),
|
|
inspectionIntervalDays: Number.isFinite(inspectionIntervalDays) ? inspectionIntervalDays : 30,
|
|
status: body.status?.trim() || "rendben",
|
|
notes: body.notes?.trim() || ""
|
|
};
|
|
}
|
|
|
|
function validateLogPayload(payload) {
|
|
if (!payload.elevatorId && (!payload.elevatorName || !payload.serialNumber || !payload.location)) {
|
|
return "A lift neve, gyári száma és helyszíne kötelező.";
|
|
}
|
|
|
|
if (!payload.inspectionDate || !payload.nextInspectionDate) {
|
|
return "Az ellenőrzés és a következő ellenőrzés dátuma kötelező.";
|
|
}
|
|
|
|
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
|
|
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function removeUploadedFiles(files) {
|
|
files.forEach((file) => {
|
|
const absolutePath = path.join(UPLOAD_DIR, file.filename);
|
|
|
|
if (fs.existsSync(absolutePath)) {
|
|
fs.unlinkSync(absolutePath);
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildLogOrder(sortBy) {
|
|
const orderMap = {
|
|
inspectionDateAsc: "l.inspection_date ASC, l.id DESC",
|
|
inspectionDateDesc: "l.inspection_date DESC, l.id DESC",
|
|
nextInspectionAsc: "l.next_inspection_date ASC, l.id DESC",
|
|
nextInspectionDesc: "l.next_inspection_date DESC, l.id DESC",
|
|
elevatorNameAsc: "l.elevator_name ASC, l.id DESC",
|
|
statusAsc: "l.status ASC, l.id DESC"
|
|
};
|
|
|
|
return orderMap[sortBy] || orderMap.inspectionDateDesc;
|
|
}
|
|
|
|
async function fetchLogById(logId) {
|
|
const result = await pool.query(
|
|
`SELECT l.id, l.elevator_id, l.elevator_name, l.serial_number, l.location, l.route_name, l.inspection_date,
|
|
l.next_inspection_date, l.inspection_interval_days, l.status, l.notes,
|
|
l.technician_name, l.created_by_user_id, l.created_at,
|
|
COALESCE(
|
|
JSON_AGG(
|
|
JSON_BUILD_OBJECT(
|
|
'id', lf.id,
|
|
'originalName', lf.original_name,
|
|
'size', lf.size,
|
|
'downloadPath', '/api/logs/' || l.id || '/files/' || lf.id
|
|
)
|
|
ORDER BY lf.id
|
|
) FILTER (WHERE lf.id IS NOT NULL),
|
|
'[]'::json
|
|
) AS files
|
|
FROM logs l
|
|
LEFT JOIN log_files lf ON lf.log_id = l.id
|
|
WHERE l.id = $1
|
|
GROUP BY l.id`,
|
|
[logId]
|
|
);
|
|
|
|
return result.rowCount ? mapLogRow(result.rows[0]) : null;
|
|
}
|
|
|
|
async function findElevatorById(executor, elevatorId, routeName) {
|
|
const result = await executor.query(
|
|
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
|
|
next_inspection_date, inspection_interval_days, last_status, updated_at
|
|
FROM elevators
|
|
WHERE id = $1 AND route_name = $2`,
|
|
[elevatorId, routeName]
|
|
);
|
|
|
|
return result.rowCount ? result.rows[0] : null;
|
|
}
|
|
|
|
function mapUserRow(row) {
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
fullName: row.full_name,
|
|
routeName: row.route_name
|
|
};
|
|
}
|
|
|
|
function mapElevatorRow(row) {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
serialNumber: row.serial_number,
|
|
location: row.location,
|
|
routeName: row.route_name,
|
|
lastInspectionDate: row.last_inspection_date,
|
|
nextInspectionDate: row.next_inspection_date,
|
|
inspectionIntervalDays: row.inspection_interval_days,
|
|
lastStatus: row.last_status,
|
|
updatedAt: row.updated_at
|
|
};
|
|
}
|
|
|
|
function mapLogRow(row) {
|
|
return {
|
|
id: row.id,
|
|
elevatorId: row.elevator_id,
|
|
elevatorName: row.elevator_name,
|
|
serialNumber: row.serial_number,
|
|
location: row.location,
|
|
routeName: row.route_name,
|
|
inspectionDate: row.inspection_date,
|
|
nextInspectionDate: row.next_inspection_date,
|
|
inspectionIntervalDays: row.inspection_interval_days,
|
|
status: row.status,
|
|
notes: row.notes,
|
|
technicianName: row.technician_name,
|
|
createdByUserId: row.created_by_user_id,
|
|
createdAt: row.created_at,
|
|
files: Array.isArray(row.files)
|
|
? row.files.map((file) => ({
|
|
...file,
|
|
originalName: normalizeUploadedFilename(file.originalName)
|
|
}))
|
|
: []
|
|
};
|
|
}
|
|
|
|
function normalizeUploadedFilename(value) {
|
|
if (!value || !containsLikelyFilenameMojibake(value)) {
|
|
return value;
|
|
}
|
|
|
|
const decoded = Buffer.from(value, "latin1").toString("utf8").normalize("NFC");
|
|
return containsReplacementCharacter(decoded) ? value : decoded;
|
|
}
|
|
|
|
function containsLikelyFilenameMojibake(value) {
|
|
return /[\u00C2-\u00C5\u00CC-\u00CF\u00D0-\u00D6\u00D8-\u00DE\u0080-\u009F]/.test(value);
|
|
return /[ÃÅÂ]/.test(value);
|
|
}
|
|
|
|
function containsReplacementCharacter(value) {
|
|
return value.includes("\uFFFD");
|
|
}
|