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"); }