Files
veille-reglementaire/server/_core/index.ts
Manus d283675230 Checkpoint: Purge automatique des tombstones (processed_dedup_keys) de plus de 6 mois :
- purgeOldArticles dans db.ts : suppression des entrées processedDedupKeys.processedAt <= cutoff 6 mois
- Logs cron et init mis à jour pour afficher le nombre de tombstones supprimés
- 0 erreur TypeScript
2026-07-10 21:14:55 -04:00

250 lines
9.9 KiB
TypeScript

import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import * as cron from "node-cron";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { runFullImport } from "../importer";
import uploadRoutes from "../uploadRoutes";
import scheduledRoutes from "../scheduledRoutes";
import { ensureAdminExists, generateLocalToken } from "../localAuth";
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
import { runRssFetch } from "../rssEngine";
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}`);
}
// ─── Tâche d'import quotidien + lecture RSS ──────────────────────────────────
let cronJob: ReturnType<typeof cron.schedule> | null = null;
export async function scheduleDailyImport() {
const importTime = (await getSetting("import_time")) || "06:00";
const fetchMode = (await getSetting("fetch_mode")) || "scheduled";
const fetchIntervalMinutes = parseInt((await getSetting("fetch_interval_minutes")) || "1440", 10);
const fetchDayOfWeek = parseInt((await getSetting("fetch_day_of_week")) || "1", 10); // 0=dim, 1=lun, ..., 6=sam
const fetchDayOfMonth = parseInt((await getSetting("fetch_day_of_month")) || "1", 10); // 1-28
const DAYS_FR = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
let cronExpr: string;
if (fetchMode === "interval") {
const intervalMin = Math.max(60, fetchIntervalMinutes);
if (intervalMin % 60 === 0) {
const hours = intervalMin / 60;
cronExpr = hours === 24 ? `0 0 * * *` : `0 */${hours} * * *`;
} else {
cronExpr = `*/${intervalMin} * * * *`;
}
console.log(`[Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`);
} else if (fetchMode === "weekly") {
const [hour, minute] = importTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * ${fetchDayOfWeek}`;
console.log(`[Cron] Mode hebdomadaire — chaque ${DAYS_FR[fetchDayOfWeek] ?? "lundi"} à ${importTime} (${cronExpr})`);
} else if (fetchMode === "monthly") {
const [hour, minute] = importTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} ${fetchDayOfMonth} * *`;
console.log(`[Cron] Mode mensuel — le ${fetchDayOfMonth} de chaque mois à ${importTime} (${cronExpr})`);
} else {
// scheduled (heure fixe quotidienne) ou daily
const [hour, minute] = importTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
console.log(`[Cron] Mode quotidien — tous les jours à ${importTime} (${cronExpr})`);
}
if (cronJob) {
cronJob.stop();
cronJob = null;
}
cronJob = cron.schedule(cronExpr, async () => {
console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`);
try {
// 1. Import des fichiers Excel (Veille + AAP)
const result = await runFullImport();
console.log(`[Cron] Import Excel terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`);
} catch (e) {
console.error("[Cron] Erreur lors de l'import Excel:", e);
}
try {
// 2. Lecture des flux RSS
const rssSummary = await runRssFetch();
console.log(
`[Cron] Lecture RSS terminée — ${rssSummary.totalFeeds} flux, ` +
`+${rssSummary.totalNewItems} nouveaux articles, ` +
`${rssSummary.errorFeeds} erreur(s)`
);
} catch (e) {
console.error("[Cron] Erreur lors de la lecture RSS:", e);
}
// 3. Purge des articles selon la règle de rétention
try {
const retentionStr = await getSetting("retention_months");
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
console.error("[Cron] Erreur lors de la purge de rétention:", e);
}
});
}
/**
* Stub conservé pour compatibilité avec les imports existants dans routers.ts.
* La lecture RSS est désormais intégrée dans scheduleDailyImport().
*/
export async function scheduleRssFetch() {
// No-op : la lecture RSS est pilotée par le cron d'import quotidien (import_time)
console.log("[RSS Cron] Planificateur RSS indépendant désactivé — la lecture est pilotée par le cron d'import quotidien.");
}
async function startServer() {
const app = express();
const server = createServer(app);
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
registerOAuthRoutes(app);
app.use(uploadRoutes);
app.use(scheduledRoutes);
// ─── Azure AD OAuth2 callback ─────────────────────────────────────────────
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher par azureAdId puis par email
let user = await getLocalUserByAzureAdId(azureUser.azureAdId);
if (!user) user = await getLocalUserByEmail(azureUser.email);
if (!user) {
// Cr\u00e9er automatiquement avec r\u00f4le "user"
await upsertLocalUserAzure({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
role: "user",
});
user = await getLocalUserByEmail(azureUser.email);
} else if (!user.azureAdId) {
// Lier le compte existant \u00e0 Azure AD
await upsertLocalUserAzure({
email: user.email ?? azureUser.email,
azureAdId: azureUser.azureAdId,
});
}
if (!user || !user.isActive) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable"));
return;
}
// Mettre \u00e0 jour lastSignedIn
const db = await (await import("../db")).getDb();
if (db) {
const { localUsers } = await import("../../drizzle/schema");
const { eq } = await import("drizzle-orm");
await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id));
}
// G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie
const token = await generateLocalToken(user.id, user.role);
res.cookie("veille_local_auth", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext
const userPayload = JSON.stringify({
id: user.id,
name: user.name,
username: user.username ?? null,
email: user.email ?? null,
role: user.role,
});
// Rediriger vers une page de callback qui hydrate le contexte
res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`);
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
app.use(
"/api/trpc",
createExpressMiddleware({ router: appRouter, createContext })
);
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, async () => {
console.log(`Server running on http://localhost:${port}/`);
// Initialisation post-démarrage
try {
await ensureAdminExists();
await scheduleDailyImport();
// Purge de rétention au démarrage
const retentionStr = await getSetting("retention_months");
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
console.error("[Init] Erreur d'initialisation:", e);
}
});
}
startServer().catch(console.error);