import Fastify from "fastify"; import type { IncomingMessage, ServerResponse } from "http"; import type { ScheduledTask } from "node-cron"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; import path from "path"; import { config } from "./config/env"; import { securityHeaders } from "./middleware/security"; import { registerVersionHeader } from "./middleware/version"; import prisma from "./config/database"; import authRoutes from "./routes/admin/auth"; import usersRoutes from "./routes/admin/users"; import rolesRoutes from "./routes/admin/roles"; import attendanceRoutes from "./routes/admin/attendance"; import customersRoutes from "./routes/admin/customers"; import invoicesRoutes from "./routes/admin/invoices"; import issuedOrdersRoutes from "./routes/admin/issued-orders"; import quotationsRoutes from "./routes/admin/quotations"; import ordersRoutes from "./routes/admin/orders"; import projectsRoutes from "./routes/admin/projects"; import tripsRoutes from "./routes/admin/trips"; import vehiclesRoutes from "./routes/admin/vehicles"; import leaveRequestsRoutes from "./routes/admin/leave-requests"; import bankAccountsRoutes from "./routes/admin/bank-accounts"; import companySettingsRoutes from "./routes/admin/company-settings"; import receivedInvoicesRoutes from "./routes/admin/received-invoices"; import dashboardRoutes from "./routes/admin/dashboard"; import auditLogRoutes from "./routes/admin/audit-log"; import profileRoutes from "./routes/admin/profile"; import sessionsRoutes from "./routes/admin/sessions"; import totpRoutes from "./routes/admin/totp"; import scopeTemplatesRoutes from "./routes/admin/scope-templates"; import invoicesPdfRoutes from "./routes/admin/invoices-pdf"; import issuedOrdersPdfRoutes from "./routes/admin/issued-orders-pdf"; import offersPdfRoutes from "./routes/admin/offers-pdf"; import ordersPdfRoutes from "./routes/admin/orders-pdf"; import projectFilesRoutes from "./routes/admin/project-files"; import warehouseRoutes from "./routes/admin/warehouse"; import planRoutes from "./routes/admin/plan"; import aiRoutes from "./routes/admin/ai"; import aresRoutes from "./routes/admin/ares"; const app = Fastify({ logger: { level: config.isProduction ? "warn" : "info", }, trustProxy: config.trustProxy.length > 0 ? config.trustProxy : ["127.0.0.1", "::1"], bodyLimit: config.nas.maxUploadSize, }); async function start() { let invoiceAlertCron: ScheduledTask | null = null; // --- Plugins --- await app.register(cors, { origin: config.appEnv === "local" ? [ /^http:\/\/127\.0\.0\.1:\d+$/, /^http:\/\/localhost:\d+$/, /^http:\/\/192\.168\.\d+\.\d+:\d+$/, ] : config.cors.origins, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], }); await app.register(cookie); // --- Health check (before rate-limit so monitoring isn't throttled) --- // Pings the database so load balancers / PM2 can detect a broken instance. // Returns 503 with { status: "unhealthy", ... } if the DB is unreachable. app.get("/api/health", async (_request, reply) => { try { await prisma.$queryRaw`SELECT 1`; } catch (err) { reply.status(503); return { status: "unhealthy", database: "unreachable", error: err instanceof Error ? err.message : "unknown", timestamp: new Date().toISOString(), }; } return { status: "ok", database: "reachable", timestamp: new Date().toISOString(), }; }); await app.register(rateLimit, { max: config.rateLimit.max, timeWindow: config.rateLimit.timeWindow, }); // --- Security headers --- app.addHook("onRequest", securityHeaders); // --- Version header: lets the SPA detect a new deploy and prompt a refresh --- registerVersionHeader(app); // --- Global error handler — consistent { success, error } format --- app.setErrorHandler( (err: Error & { statusCode?: number }, request, reply) => { const statusCode = err.statusCode ?? 500; if (statusCode >= 500) { request.log.error(err); } reply.status(statusCode).send({ success: false, error: statusCode >= 500 ? "Interní chyba serveru" : err.message || "Chyba požadavku", }); }, ); // --- API Routes --- await app.register(authRoutes, { prefix: "/api/admin" }); await app.register(usersRoutes, { prefix: "/api/admin/users" }); await app.register(rolesRoutes, { prefix: "/api/admin/roles" }); await app.register(attendanceRoutes, { prefix: "/api/admin/attendance" }); await app.register(customersRoutes, { prefix: "/api/admin/customers" }); await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" }); await app.register(issuedOrdersRoutes, { prefix: "/api/admin/issued-orders", }); await app.register(issuedOrdersPdfRoutes, { prefix: "/api/admin/issued-orders-pdf", }); await app.register(quotationsRoutes, { prefix: "/api/admin/offers" }); await app.register(ordersRoutes, { prefix: "/api/admin/orders" }); await app.register(projectsRoutes, { prefix: "/api/admin/projects" }); await app.register(tripsRoutes, { prefix: "/api/admin/trips" }); await app.register(vehiclesRoutes, { prefix: "/api/admin/vehicles" }); await app.register(leaveRequestsRoutes, { prefix: "/api/admin/leave-requests", }); await app.register(bankAccountsRoutes, { prefix: "/api/admin/bank-accounts", }); await app.register(companySettingsRoutes, { prefix: "/api/admin/company-settings", }); await app.register(receivedInvoicesRoutes, { prefix: "/api/admin/received-invoices", }); await app.register(dashboardRoutes, { prefix: "/api/admin/dashboard" }); await app.register(auditLogRoutes, { prefix: "/api/admin/audit-log" }); await app.register(profileRoutes, { prefix: "/api/admin/profile" }); await app.register(sessionsRoutes, { prefix: "/api/admin/sessions" }); await app.register(totpRoutes, { prefix: "/api/admin/totp" }); await app.register(scopeTemplatesRoutes, { prefix: "/api/admin/offers-templates", }); await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" }); await app.register(ordersPdfRoutes, { prefix: "/api/admin/orders-pdf" }); await app.register(projectFilesRoutes, { prefix: "/api/admin/project-files", }); await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); await app.register(planRoutes, { prefix: "/api/admin/plan" }); await app.register(aiRoutes, { prefix: "/api/admin/ai" }); await app.register(aresRoutes, { prefix: "/api/admin/ares" }); // --- Frontend: Vite dev middleware (dev only) --- if (!config.isProduction) { // `vite` is a dev-only dependency loaded via a runtime import the bundler // can't see (the Function() trick), so its types aren't resolvable under the // server's moduleResolution — describe just the slice we use. type ViteDevServerLike = { middlewares: ( req: IncomingMessage, res: ServerResponse, next: (err?: Error) => void, ) => void; }; type ViteModuleLike = { createServer: (opts: { server: { middlewareMode: boolean }; appType: string; }) => Promise; }; const viteModule = await (Function( 'return import("vite")', )() as Promise); const vite = await viteModule.createServer({ server: { middlewareMode: true }, appType: "spa", }); app.addHook("onRequest", (request, reply, done) => { if (request.url.startsWith("/api/")) { done(); return; } vite.middlewares(request.raw, reply.raw, done); }); app.setNotFoundHandler((request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ success: false, error: "Not found" }); } if (!reply.raw.headersSent) { vite.middlewares(request.raw, reply.raw, () => { reply.raw.statusCode = 404; reply.raw.end(); }); } }); } // --- Frontend: static file serving (production) --- if (config.isProduction) { const fastifyStatic = (await import("@fastify/static")).default; await app.register(fastifyStatic, { root: path.join(__dirname, "..", "dist-client"), prefix: "/", wildcard: false, // The plugin's own Cache-Control management must be OFF or it // overwrites the setHeaders values below with "public, max-age=0" // (README: "To provide a custom Cache-Control header, set this option // to false"). ETags stay on, so no-cache still revalidates cheaply. cacheControl: false, // Deploy-skew caching contract (per the Vite deploy guide): // - /assets/* names are content-hashed → safe to cache forever // (a changed file always gets a NEW name, so "immutable" is correct). // - index.html (and any non-hashed file) must always be revalidated, // otherwise a cached copy keeps referencing deleted old chunks. setHeaders: (res, filePath) => { if (filePath.includes(`${path.sep}assets${path.sep}`)) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else { res.setHeader("Cache-Control", "no-cache"); } }, }); app.setNotFoundHandler((request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ success: false, error: "Not found" }); } // A missing hashed asset (old chunk after a deploy) must 404, NOT get // the SPA index.html fallback — HTML-as-JS masks the failure and breaks // the client's vite:preloadError reload recovery. if (request.url.startsWith("/assets/")) { return reply.status(404).send(); } return reply.sendFile("index.html"); }); } // --- Invoice alert cron (daily at 8:00 AM) --- if (config.email.invoiceAlert) { const cron = await import("node-cron"); invoiceAlertCron = cron.default.schedule("0 8 * * *", async () => { try { const { checkInvoiceAlerts } = await import("./services/invoice-alerts"); await checkInvoiceAlerts(); } catch (err) { app.log.error(err, "Invoice alert cron failed"); } }); app.log.info("Invoice alert cron scheduled (daily 8:00)"); } // --- Start --- // Dev listens on a fixed local port (prod uses config.port / the PORT env). // 3050 keeps it clear of other local apps that commonly grab 3000. const port = config.isProduction ? config.port : 3050; try { await app.listen({ port, host: config.host }); app.log.info(`Server running on http://${config.host}:${port}`); } catch (err) { app.log.error(err); process.exit(1); } const shutdown = async (signal: string) => { app.log.info(`${signal} received, shutting down gracefully...`); try { if (invoiceAlertCron) { invoiceAlertCron.stop(); } await app.close(); const { default: prisma } = await import("./config/database"); await prisma.$disconnect(); const { closeBrowser } = await import("./utils/html-to-pdf"); await closeBrowser(); app.log.info("Server shut down successfully"); process.exit(0); } catch (err) { app.log.error(err, "Error during shutdown"); process.exit(1); } }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); } start();