portail-associations/server/_core/index.ts

217 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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é à laccueil 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);