217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
import "dotenv/config";
|
||
import express from "express";
|
||
import { createServer } from "http";
|
||
import net from "net";
|
||
import path from "node:path";
|
||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||
import { appRouter, computeReservationAnalytics, runStatsReportScheduler } from "../routers";
|
||
import { createContext } from "./context";
|
||
import { serveStatic, setupVite } from "./vite";
|
||
import { registerEmailActionRoutes } from "../emailActions";
|
||
import { registerSocialAuthRoutes } from "../socialAuth";
|
||
import { ENV } from "./env";
|
||
import { runMaterialReturnScheduler } from "../materialReturnWorkflow";
|
||
import { authenticateRequest, runUserPurgeScheduler } from "./auth";
|
||
import { withEffectiveInternalAccess } from "../internalAccess";
|
||
import { buildRuntimeHealth, ensureMaintenanceFlagDirectory, isMaintenanceModeEnabled, markRuntimeReady, markRuntimeShuttingDown } from "./runtime";
|
||
|
||
function isPortAvailable(port: number): Promise<boolean> {
|
||
return new Promise(resolve => {
|
||
const server = net.createServer();
|
||
server.listen(port, () => {
|
||
server.close(() => resolve(true));
|
||
});
|
||
server.on("error", () => resolve(false));
|
||
});
|
||
}
|
||
|
||
async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||
for (let port = startPort; port < startPort + 20; port++) {
|
||
if (await isPortAvailable(port)) {
|
||
return port;
|
||
}
|
||
}
|
||
throw new Error(`No available port found starting from ${startPort}`);
|
||
}
|
||
|
||
async function startServer() {
|
||
const app = express();
|
||
const server = createServer(app);
|
||
const schedulerTimers: NodeJS.Timeout[] = [];
|
||
const openSockets = new Set<net.Socket>();
|
||
let shutdownInFlight = false;
|
||
|
||
await ensureMaintenanceFlagDirectory();
|
||
|
||
server.on("connection", (socket) => {
|
||
openSockets.add(socket);
|
||
socket.on("close", () => {
|
||
openSockets.delete(socket);
|
||
});
|
||
});
|
||
|
||
async function sendHealth(res: express.Response, mode: "live" | "ready") {
|
||
const health = await buildRuntimeHealth();
|
||
const status = mode === "live"
|
||
? (health.checks.shuttingDown ? 503 : 200)
|
||
: (health.ok ? 200 : 503);
|
||
res.status(status).json(health);
|
||
}
|
||
|
||
function renderMaintenancePage() {
|
||
return `<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>Maintenance en cours</title>
|
||
<style>
|
||
body{margin:0;font-family:Arial,sans-serif;background:#f6f9fc;color:#162133;display:grid;place-items:center;min-height:100vh;padding:24px}
|
||
.panel{max-width:560px;background:#fff;border:1px solid #d5e2f3;border-radius:20px;padding:32px;box-shadow:0 12px 30px rgba(15,23,42,.08)}
|
||
h1{margin:0 0 12px;font-size:32px;line-height:1.1}
|
||
p{margin:0 0 10px;font-size:16px;line-height:1.6;color:#52627a}
|
||
.badge{display:inline-flex;padding:6px 12px;border-radius:999px;background:#e8f2ff;color:#2c63c9;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:18px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="panel">
|
||
<div class="badge">Maintenance</div>
|
||
<h1>Le portail revient dans un instant</h1>
|
||
<p>Nous appliquons une mise à jour de service pour garder une expérience stable, propre et fiable.</p>
|
||
<p>Tu peux recharger la page dans quelques instants.</p>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
async function gracefulShutdown(signal: string) {
|
||
if (shutdownInFlight) return;
|
||
shutdownInFlight = true;
|
||
console.log(`[Runtime] graceful shutdown triggered by ${signal}`);
|
||
markRuntimeShuttingDown();
|
||
schedulerTimers.forEach(clearTimeout);
|
||
schedulerTimers.splice(0, schedulerTimers.length);
|
||
|
||
const forceCloseTimer = setTimeout(() => {
|
||
for (const socket of Array.from(openSockets)) {
|
||
socket.destroy();
|
||
}
|
||
}, ENV.gracefulShutdownTimeoutMs);
|
||
|
||
await new Promise<void>((resolve) => {
|
||
server.close(() => resolve());
|
||
});
|
||
|
||
clearTimeout(forceCloseTimer);
|
||
process.exit(0);
|
||
}
|
||
|
||
process.on("SIGTERM", () => {
|
||
void gracefulShutdown("SIGTERM");
|
||
});
|
||
process.on("SIGINT", () => {
|
||
void gracefulShutdown("SIGINT");
|
||
});
|
||
|
||
// Configure body parser with larger size limit for file uploads
|
||
app.use(express.json({ limit: "50mb" }));
|
||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||
app.use("/uploads", express.static(path.resolve(process.cwd(), "uploads")));
|
||
app.get("/health/live", async (_req, res) => {
|
||
await sendHealth(res, "live");
|
||
});
|
||
app.get("/health/ready", async (_req, res) => {
|
||
await sendHealth(res, "ready");
|
||
});
|
||
app.get("/health", async (_req, res) => {
|
||
await sendHealth(res, "ready");
|
||
});
|
||
// Email action routes for validate/refuse from email
|
||
registerEmailActionRoutes(app);
|
||
registerSocialAuthRoutes(app);
|
||
app.get("/api/internal/stats/reservations", async (req, res) => {
|
||
try {
|
||
let authenticatedUser;
|
||
try {
|
||
authenticatedUser = await withEffectiveInternalAccess(await authenticateRequest(req));
|
||
} catch {
|
||
return res.status(401).json({ error: "Authentification requise" });
|
||
}
|
||
if (!authenticatedUser) {
|
||
return res.status(401).json({ error: "Authentification requise" });
|
||
}
|
||
const allowedRoles = new Set(["accueil", "admin", "super_admin"]);
|
||
if (!allowedRoles.has(authenticatedUser.role)) {
|
||
return res.status(403).json({ error: "Accès réservé à l’accueil et aux administrateurs" });
|
||
}
|
||
|
||
const rawPeriod = typeof req.query.period === "string" ? req.query.period : "month";
|
||
const period = ["7d", "month", "quarter", "semester", "year"].includes(rawPeriod) ? rawPeriod as "7d" | "month" | "quarter" | "semester" | "year" : "month";
|
||
const payload = await computeReservationAnalytics(period);
|
||
return res.json(payload);
|
||
} catch {
|
||
return res.status(500).json({ error: "Impossible de récupérer les statistiques" });
|
||
}
|
||
});
|
||
// tRPC API
|
||
app.use(
|
||
"/api/trpc",
|
||
createExpressMiddleware({
|
||
router: appRouter,
|
||
createContext,
|
||
})
|
||
);
|
||
|
||
app.use(async (req, res, next) => {
|
||
if (!["GET", "HEAD"].includes(req.method)) {
|
||
next();
|
||
return;
|
||
}
|
||
if (req.path.startsWith("/api") || req.path.startsWith("/health") || req.path.startsWith("/uploads")) {
|
||
next();
|
||
return;
|
||
}
|
||
if (!(await isMaintenanceModeEnabled())) {
|
||
next();
|
||
return;
|
||
}
|
||
res.status(503).type("html").send(renderMaintenancePage());
|
||
});
|
||
|
||
// development mode uses Vite, production mode uses static files
|
||
if (process.env.NODE_ENV === "development") {
|
||
await setupVite(app, server);
|
||
} else {
|
||
serveStatic(app);
|
||
}
|
||
|
||
const preferredPort = parseInt(process.env.PORT || "3000");
|
||
const port = await findAvailablePort(preferredPort);
|
||
|
||
if (port !== preferredPort) {
|
||
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
|
||
}
|
||
|
||
server.listen(port, () => {
|
||
console.log(`Server running on http://localhost:${port}/`);
|
||
|
||
const baseUrl = (ENV.appBaseUrl || `http://localhost:${port}`).replace(/\/$/, "");
|
||
const runScheduler = () => {
|
||
runMaterialReturnScheduler(baseUrl).catch((error) => {
|
||
console.error("[MaterialReturnScheduler] execution error:", error);
|
||
});
|
||
runStatsReportScheduler(baseUrl).catch((error) => {
|
||
console.error("[StatsReportScheduler] execution error:", error);
|
||
});
|
||
runUserPurgeScheduler().catch((error) => {
|
||
console.error("[UserPurgeScheduler] execution error:", error);
|
||
});
|
||
};
|
||
|
||
markRuntimeReady();
|
||
schedulerTimers.push(setTimeout(runScheduler, 5_000));
|
||
schedulerTimers.push(setInterval(runScheduler, 60 * 60 * 1000));
|
||
});
|
||
}
|
||
|
||
startServer().catch(console.error);
|