import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { getVeilleItems, getVeilleDistinctValues, getAapItems, getAapDistinctValues, purgeVeilleItems, purgeAapItems, getAllSettings, setSettings, getImportLogs, getImportStats, getClassificationErrors, getLocalUsers, createLocalUser, updateLocalUser, deleteLocalUser, createIdea, getAllIdeas, getIdeasByUser, repondreIdea, updateIdeaStatut, getRssFeeds, getRssFeedById, createRssFeed, updateRssFeed, deleteRssFeed, getRssSettings, saveRssSettings, getDb, getReadArticleIds, getUnreadArticleCount, markArticlesAsRead, removeArticleReadRecords, } from "./db"; import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; import { scheduleDailyImport } from "./_core/index"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth"; import { classifyAap, classifyArticle } from "./aiClassifier"; import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema"; import { isNull, or, eq as eqDrizzle } from "drizzle-orm"; // ─── Middleware admin ───────────────────────────────────────────────────────── const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" }); } return next({ ctx }); }); // ─── Router principal ───────────────────────────────────────────────────────── export const appRouter = router({ system: systemRouter, // ─── Auth ─────────────────────────────────────────────────────────────────── auth: router({ me: publicProcedure.query((opts) => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), // Connexion locale localLogin: publicProcedure .input(z.object({ identifier: z.string().min(1), password: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const result = await loginLocalUser(input.identifier, input.password); // Stocker le token dans un cookie const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.cookie("veille_local_auth", result.token, { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000, }); return { success: true, user: result.user }; }), localLogout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 }); return { success: true }; }), // Azure AD isAzureAdAvailable: publicProcedure.query(() => { return { available: isAzureAdConfigured() }; }), getAzureLoginUrl: publicProcedure.query(async () => { if (!isAzureAdConfigured()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD non configur\u00e9" }); } const url = await getAzureAuthUrl(); return { url }; }), }), // ─── Veille ───────────────────────────────────────────────────────────────── veille: router({ list: publicProcedure .input( z.object({ typeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(), categorie: z.string().optional(), niveau: z.string().optional(), territoire: z.string().optional(), search: z.string().optional(), dateFrom: z.date().optional(), dateTo: z.date().optional(), page: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(200).default(50), }) ) .query(async ({ input }) => { return getVeilleItems(input); }), filters: publicProcedure.query(async () => { return getVeilleDistinctValues(); }), purge: adminProcedure.mutation(async () => { const count = await purgeVeilleItems(); return { success: true, deleted: count }; }), reclassifyWithAI: adminProcedure .input(z.object({ limit: z.number().int().positive().max(200).default(50) })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); // Récupérer les articles non encore classés par l'IA OU classés par le fallback rules const rows = await db .select() .from(veilleItems) .where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant), eqDrizzle(veilleItems.iaClassifiedBy, "rules"))) .limit(input.limit); let processed = 0; let deleted = 0; let errors = 0; for (const row of rows) { try { const aiResult = await classifyArticle( row.titre || "", row.resume || "", () => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" }) ); if (!aiResult.relevant) { // Supprimer l'article non pertinent await removeArticleReadRecords("veille", [row.id]); await db.delete(veilleItems).where(eqDrizzle(veilleItems.id, row.id)); // Conserver le tombstone pour éviter la réinsertion if (row.dedupKey) { await db.insert(processedDedupKeys) .values({ dedupKey: row.dedupKey, feedType: "veille" }) .onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } }); } deleted++; } else { await db .update(veilleItems) .set({ typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "informationnelle") as any, iaRelevant: aiResult.relevant, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, }) .where(eqDrizzle(veilleItems.id, row.id)); processed++; } } catch { errors++; } } return { processed, deleted, errors, total: rows.length }; }), // ─── Marquage lu/non lu ───────────────────────────────────────────────── markAsRead: protectedProcedure .input(z.object({ articleId: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const marked = await markArticlesAsRead(ctx.user.id, "veille", [input.articleId]); return { success: true, marked }; }), markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); // Les IDs sont lus à l'instant de la mutation : aucun élément filtré ne peut être marqué par erreur. const allItems = await db.select({ id: veilleItems.id }).from(veilleItems); const marked = await markArticlesAsRead(ctx.user.id, "veille", allItems.map((item: { id: number }) => item.id)); return { success: true, marked }; }), unreadCount: protectedProcedure.query(async ({ ctx }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); const totalRows = await db.select({ id: veilleItems.id }).from(veilleItems); return { count: await getUnreadArticleCount(ctx.user.id, "veille", totalRows.length) }; }), getReadIds: protectedProcedure.query(async ({ ctx }) => { return { ids: await getReadArticleIds(ctx.user.id, "veille") }; }), }), // ─── AAPP ──────────────────────────────────────────────────────────────────── aap: router({ list: publicProcedure .input( z.object({ categorie: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(), region: z.string().optional(), departement: z.string().optional(), search: z.string().optional(), dateFrom: z.date().optional(), dateTo: z.date().optional(), clotureFrom: z.date().optional(), clotureTo: z.date().optional(), page: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(200).default(50), }) ) .query(async ({ input }) => { return getAapItems(input); }), filters: publicProcedure.query(async () => { return getAapDistinctValues(); }), purge: adminProcedure.mutation(async () => { const count = await purgeAapItems(); return { success: true, deleted: count }; }), reclassifyWithAI: adminProcedure .input(z.object({ limit: z.number().int().positive().max(200).default(50) })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); const rows = await db .select() .from(aapItems) .where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant), eqDrizzle(aapItems.iaClassifiedBy, "rules"))) .limit(input.limit); let processed = 0; let deleted = 0; let errors = 0; for (const row of rows) { try { const aiResult = await classifyAap( row.titre || "", row.resume || "", row.categorie ); if (!aiResult.relevant) { await removeArticleReadRecords("aap", [row.id]); await db.delete(aapItems).where(eqDrizzle(aapItems.id, row.id)); if (row.dedupKey) { await db.insert(processedDedupKeys) .values({ dedupKey: row.dedupKey, feedType: "aap" }) .onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } }); } deleted++; } else { await db .update(aapItems) .set({ categorie: aiResult.categorieAap ?? row.categorie, iaRelevant: aiResult.relevant, iaCategorie: aiResult.categorieAap, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, }) .where(eqDrizzle(aapItems.id, row.id)); processed++; } } catch { errors++; } } return { processed, deleted, errors, total: rows.length }; }), // ─── Marquage lu/non lu AAP ─────────────────────────────────────────────── markAsRead: protectedProcedure .input(z.object({ articleId: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const marked = await markArticlesAsRead(ctx.user.id, "aap", [input.articleId]); return { success: true, marked }; }), markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); const allItems = await db.select({ id: aapItems.id }).from(aapItems); const marked = await markArticlesAsRead(ctx.user.id, "aap", allItems.map((item: { id: number }) => item.id)); return { success: true, marked }; }), unreadCount: protectedProcedure.query(async ({ ctx }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); const totalRows = await db.select({ id: aapItems.id }).from(aapItems); return { count: await getUnreadArticleCount(ctx.user.id, "aap", totalRows.length) }; }), getReadIds: protectedProcedure.query(async ({ ctx }) => { return { ids: await getReadArticleIds(ctx.user.id, "aap") }; }), }), // ─── Import ───────────────────────────────────────────────────────────────── import: router({ run: adminProcedure .input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") })) .mutation(async ({ input }) => { const config = await getImportConfig(); if (input.type === "all") return runFullImport(); if (input.type === "veille") return { veille: await importVeille(config) }; return { aap: await importAAP(config) }; }), logs: adminProcedure .input(z.object({ page: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(100).default(20) })) .query(async ({ input }) => { const allLogs = await getImportLogs(500); const start = (input.page - 1) * input.pageSize; const logs = allLogs.slice(start, start + input.pageSize); const stats = await getImportStats(); return { logs, total: allLogs.length, stats }; }), stats: publicProcedure.query(async () => { return getImportStats(); }), }), // ─── Paramètres ───────────────────────────────────────────────────────────── settings: router({ get: adminProcedure.query(async () => { const all = await getAllSettings(); // Masquer les mots de passe const safe = { ...all }; if (safe.ftp_password) safe.ftp_password = "••••••••"; if (safe.onedrive_token) safe.onedrive_token = "••••••••"; if (safe.sharepoint_token) safe.sharepoint_token = "••••••••"; return safe; }), save: adminProcedure .input( z.object({ source_type: z.enum(["local", "onedrive", "ftp", "sharepoint"]), veille_file_path: z.string().optional(), aap_file_path: z.string().optional(), ftp_host: z.string().optional(), ftp_port: z.string().optional(), ftp_user: z.string().optional(), ftp_password: z.string().optional(), ftp_secure: z.string().optional(), onedrive_token: z.string().optional(), sharepoint_site_url: z.string().optional(), sharepoint_token: z.string().optional(), auth_mode: z.enum(["local", "free"]).optional(), import_time: z.string().optional(), fetch_mode: z.enum(["scheduled", "weekly", "monthly", "interval"]).optional(), fetch_interval_minutes: z.string().optional(), fetch_day_of_week: z.string().optional(), fetch_day_of_month: z.string().optional(), retention_months: z.string().optional(), }) ) .mutation(async ({ input }) => { const toSave: Record = {}; for (const [k, v] of Object.entries(input)) { if (v !== undefined && v !== "••••••••") toSave[k] = v; } await setSettings(toSave); // Recharger le cron si la planification a changé if (toSave.import_time || toSave.fetch_mode || toSave.fetch_interval_minutes || toSave.fetch_day_of_week || toSave.fetch_day_of_month) { await scheduleDailyImport(); } return { success: true }; }), }), // ─── Utilisateurs locaux ───────────────────────────────────────────────────── users: router({ list: adminProcedure.query(async () => { return getLocalUsers(); }), create: adminProcedure .input( z.object({ name: z.string().min(2).max(255), username: z.string().min(2).max(128).optional(), email: z.string().email().optional(), password: z.string().min(8), role: z.enum(["admin", "user", "readonly"]).default("user"), }) ) .mutation(async ({ input }) => { const passwordHash = await hashPassword(input.password); await createLocalUser({ name: input.name, username: input.username ?? null, email: input.email ? input.email.toLowerCase() : null, passwordHash, role: input.role, isActive: true, }); return { success: true }; }), update: adminProcedure .input( z.object({ id: z.number().int().positive(), name: z.string().min(2).max(255).optional(), username: z.string().min(2).max(128).optional(), email: z.string().email().optional(), password: z.string().min(8).optional(), role: z.enum(["admin", "user", "readonly"]).optional(), isActive: z.boolean().optional(), }) ) .mutation(async ({ input }) => { const { id, password, ...rest } = input; const data: Record = { ...rest }; if (password) data.passwordHash = await hashPassword(password); await updateLocalUser(id, data as Parameters[1]); return { success: true }; }), delete: adminProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input }) => { await deleteLocalUser(input.id); return { success: true }; }), ensureAdmin: publicProcedure.mutation(async () => { await ensureAdminExists(); return { success: true }; }), }), // ─── Boîte à idées ─────────────────────────────────────────────────────────── ideas: router({ // Créer une nouvelle idée / question create: protectedProcedure .input( z.object({ titre: z.string().min(3).max(512), message: z.string().min(10), }) ) .mutation(async ({ input, ctx }) => { await createIdea({ userId: ctx.user.id, userName: ctx.user.name ?? ctx.user.email ?? "Utilisateur", titre: input.titre, message: input.message, }); return { success: true }; }), // Lister toutes les idées (admin) ou les siennes (user) list: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role === "admin") { return getAllIdeas(); } return getIdeasByUser(ctx.user.id); }), // Répondre à une idée (admin uniquement) repondre: adminProcedure .input( z.object({ id: z.number().int().positive(), reponseAdmin: z.string().min(1), statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]), }) ) .mutation(async ({ input, ctx }) => { await repondreIdea( input.id, input.reponseAdmin, ctx.user.name ?? ctx.user.email ?? "Admin", input.statut ); return { success: true }; }), // Changer le statut (admin uniquement) updateStatut: adminProcedure .input( z.object({ id: z.number().int().positive(), statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]), }) ) .mutation(async ({ input }) => { await updateIdeaStatut(input.id, input.statut); return { success: true }; }), }), // ─── Flux RSS ─────────────────────────────────────────────────────────────────────────────────── rss: router({ // Lister tous les flux list: protectedProcedure.query(async () => { return getRssFeeds(); }), // Consulter les fallbacks IA par article, sans exposer les erreurs aux utilisateurs standards. classificationErrors: adminProcedure .input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(25) })) .query(async ({ input }) => getClassificationErrors(input.page, input.pageSize)), // Créer un flux create: adminProcedure .input(z.object({ url: z.string().url("URL invalide"), name: z.string().min(1, "Nom requis").max(255), feedType: z.enum(["veille", "aap"]), defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(), defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(), autoRules: z.array(z.object({ keyword: z.string(), value: z.string(), })).optional(), isActive: z.boolean().optional().default(true), })) .mutation(async ({ input }) => { const id = await createRssFeed({ url: input.url, name: input.name, feedType: input.feedType, defaultTypeVeille: input.defaultTypeVeille ?? null, defaultCategorieAap: input.defaultCategorieAap ?? null, autoRules: input.autoRules ?? null, isActive: input.isActive ?? true, }); return { id }; }), // Modifier un flux update: adminProcedure .input(z.object({ id: z.number().int().positive(), url: z.string().url("URL invalide").optional(), name: z.string().min(1).max(255).optional(), feedType: z.enum(["veille", "aap"]).optional(), defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).nullable().optional(), defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).nullable().optional(), autoRules: z.array(z.object({ keyword: z.string(), value: z.string(), })).nullable().optional(), isActive: z.boolean().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; await updateRssFeed(id, data); return { success: true }; }), // Supprimer un flux delete: adminProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input }) => { await deleteRssFeed(input.id); return { success: true }; }), // Activer / désactiver un flux toggleActive: adminProcedure .input(z.object({ id: z.number().int().positive(), isActive: z.boolean() })) .mutation(async ({ input }) => { await updateRssFeed(input.id, { isActive: input.isActive }); return { success: true }; }), // Lire les paramètres globaux RSS getSettings: protectedProcedure.query(async () => { return getRssSettings(); }), // Sauvegarder les paramètres globaux RSS saveSettings: adminProcedure .input(z.object({ fetchIntervalMinutes: z.number().int().min(5).max(10080).optional(), scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, "Format HH:MM requis").optional(), fetchMode: z.enum(["interval", "scheduled"]).optional(), autoFetchEnabled: z.boolean().optional(), })) .mutation(async ({ input }) => { await saveRssSettings(input); // Recharger le planificateur (la lecture RSS est intégrée dans scheduleDailyImport) await scheduleDailyImport(); return { success: true }; }), }), }); export type AppRouter = typeof appRouter;