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 { 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 { 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(); 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 ` Maintenance en cours
Maintenance

Le portail revient dans un instant

Nous appliquons une mise à jour de service pour garder une expérience stable, propre et fiable.

Tu peux recharger la page dans quelques instants.

`; } 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((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);