- feat: order confirmation PDF generation with VAT support - feat: order confirmation modal with custom item editing - fix: attendance negative duration clamping and switchProject timing - fix: Quill editor locked to Tahoma 14px, PDF heading sizes - fix: invoice/offer PDF font consistency (Tahoma enforcement) - fix: invoice alert cron improvements - fix: NAS financials manager edge cases - refactor: numbering service with unique sequence constraints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
230 lines
7.9 KiB
TypeScript
230 lines
7.9 KiB
TypeScript
import Fastify from "fastify";
|
|
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 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 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 offersPdfRoutes from "./routes/admin/offers-pdf";
|
|
import ordersPdfRoutes from "./routes/admin/orders-pdf";
|
|
import projectFilesRoutes from "./routes/admin/project-files";
|
|
|
|
const app = Fastify({
|
|
logger: {
|
|
level: config.isProduction ? "warn" : "info",
|
|
},
|
|
trustProxy: config.isProduction
|
|
? ["127.0.0.1", "192.168.50.100"]
|
|
: ["127.0.0.1", "::1"],
|
|
bodyLimit: 1048576,
|
|
});
|
|
|
|
async function start() {
|
|
// --- 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) ---
|
|
app.get("/api/health", async () => ({
|
|
status: "ok",
|
|
timestamp: new Date().toISOString(),
|
|
}));
|
|
|
|
await app.register(rateLimit, {
|
|
max: 300,
|
|
timeWindow: "1 minute",
|
|
});
|
|
|
|
// --- Security headers ---
|
|
app.addHook("onRequest", securityHeaders);
|
|
|
|
// --- 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(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",
|
|
});
|
|
|
|
// --- Frontend: Vite dev middleware (dev only) ---
|
|
if (!config.isProduction) {
|
|
const viteModule = await (Function(
|
|
'return import("vite")',
|
|
)() as Promise<any>);
|
|
const createViteServer = viteModule.createServer as (
|
|
opts: any,
|
|
) => Promise<any>;
|
|
const vite = await createViteServer({
|
|
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,
|
|
});
|
|
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.url.startsWith("/api/")) {
|
|
return reply.status(404).send({ success: false, error: "Not found" });
|
|
}
|
|
return reply.sendFile("index.html");
|
|
});
|
|
}
|
|
|
|
// --- Invoice alert cron (daily at 8:00 AM) ---
|
|
if (config.email.invoiceAlert) {
|
|
const cron = await import("node-cron");
|
|
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 ---
|
|
const port = config.isProduction ? config.port : 3000;
|
|
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 {
|
|
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();
|