From 716431ede3438093f0a29423a2dd46009a9412bd Mon Sep 17 00:00:00 2001 From: manus-admin Date: Fri, 9 Oct 2026 15:46:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20purger=20les=20contenus=20expir=C3=A9s?= =?UTF-8?q?=20ou=20anciens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/AAPDashboard.tsx | 67 +++++++++++++++++++++++++--- client/src/pages/VeilleDashboard.tsx | 64 +++++++++++++++++++++++--- server/db.ts | 49 ++++++++++++++++++++ server/routers.ts | 51 +++++++++++++++++---- server/veille.test.ts | 42 +++++++++++++++++ 5 files changed, 255 insertions(+), 18 deletions(-) diff --git a/client/src/pages/AAPDashboard.tsx b/client/src/pages/AAPDashboard.tsx index 6ca5c00..5a7d8ac 100644 --- a/client/src/pages/AAPDashboard.tsx +++ b/client/src/pages/AAPDashboard.tsx @@ -41,6 +41,7 @@ import { fr } from "date-fns/locale"; type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; type ReadFilter = "unread" | "all" | "read"; +type AapPurgeScope = "all" | "expired" | "older_than"; interface AAPItem { id: number; @@ -78,6 +79,7 @@ const CAT_ACCENT: Record = { }; const PAGE_SIZE = 24; +const PURGE_MONTH_OPTIONS = [1, 3, 6, 12, 18, 24] as const; function formatDate(d: Date | null | undefined): string | null { if (!d) return null; @@ -137,12 +139,16 @@ export default function AAPDashboard() { }); const unreadCountQuery = trpc.aap.unreadCount.useQuery(); const unreadCount = unreadCountQuery.data?.count ?? 0; + const [purgeScope, setPurgeScope] = useState("expired"); + const [purgeMonths, setPurgeMonths] = useState<(typeof PURGE_MONTH_OPTIONS)[number]>(6); const purgeMutation = trpc.aap.purge.useMutation({ onSuccess: (data) => { toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`); utils.aap.list.invalidate(); utils.aap.filters.invalidate(); + utils.aap.unreadCount.invalidate(); + utils.aap.getReadIds.invalidate(); }, onError: (err) => { toast.error(`Erreur lors de la purge : ${err.message}`); @@ -258,11 +264,58 @@ export default function AAPDashboard() { - Purger tous les appels à projets + Purger les appels à projets -
-

Cette action va supprimer définitivement tous les appels à projets.

+
+

Choisissez les AAP à supprimer définitivement.

+
+ {([ + ["expired", "AAP clôturés"], + ["older_than", "AAP plus anciens"], + ["all", "Tous les AAP"], + ] as const).map(([scope, label]) => ( + + ))} +
+ {purgeScope === "older_than" && ( +
+ {PURGE_MONTH_OPTIONS.map((months) => ( + + ))} +
+ )} +

+ {purgeScope === "expired" + ? "Seuls les AAP dont la date de clôture est dépassée seront supprimés." + : purgeScope === "all" + ? "Tous les appels à projets seront supprimés." + : `Les AAP publiés, ou importés sans date de publication, il y a plus de ${purgeMonths} mois seront supprimés.`} +

Cette opération est irréversible.

@@ -271,10 +324,14 @@ export default function AAPDashboard() { Annuler purgeMutation.mutate()} + onClick={() => purgeMutation.mutate( + purgeScope === "older_than" + ? { scope: "older_than", months: purgeMonths } + : { scope: purgeScope }, + )} disabled={purgeMutation.isPending} > - {purgeMutation.isPending ? "Purge en cours..." : "Oui, purger tous les appels à projets"} + {purgeMutation.isPending ? "Purge en cours..." : "Oui, supprimer définitivement"} diff --git a/client/src/pages/VeilleDashboard.tsx b/client/src/pages/VeilleDashboard.tsx index 5e60176..f464810 100644 --- a/client/src/pages/VeilleDashboard.tsx +++ b/client/src/pages/VeilleDashboard.tsx @@ -48,6 +48,7 @@ import { fr } from "date-fns/locale"; type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"; type ReadFilter = "unread" | "all" | "read"; +type VeillePurgeScope = "all" | "older_than"; interface VeilleItem { id: number; @@ -97,6 +98,7 @@ const TYPE_HEADER_BG: Record = { }; const PAGE_SIZE = 24; +const PURGE_MONTH_OPTIONS = [1, 3, 6, 12, 18, 24] as const; function formatDate(d: Date | null | undefined): string | null { if (!d) return null; @@ -254,12 +256,16 @@ export default function VeilleDashboard() { }); const unreadCountQuery = trpc.veille.unreadCount.useQuery(); const unreadCount = unreadCountQuery.data?.count ?? 0; + const [purgeScope, setPurgeScope] = useState("older_than"); + const [purgeMonths, setPurgeMonths] = useState<(typeof PURGE_MONTH_OPTIONS)[number]>(6); const purgeMutation = trpc.veille.purge.useMutation({ onSuccess: (data) => { toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`); utils.veille.list.invalidate(); utils.veille.filters.invalidate(); + utils.veille.unreadCount.invalidate(); + utils.veille.getReadIds.invalidate(); }, onError: (err) => { toast.error(`Erreur lors de la purge : ${err.message}`); @@ -381,11 +387,55 @@ export default function VeilleDashboard() { - Purger toutes les données de veille + Purger la veille stratégique -
-

Cette action va supprimer définitivement toutes les entrées de la veille stratégique.

+
+

Choisissez les articles à supprimer définitivement.

+
+ {([ + ["older_than", "Articles plus anciens"], + ["all", "Tous les articles"], + ] as const).map(([scope, label]) => ( + + ))} +
+ {purgeScope === "older_than" && ( +
+ {PURGE_MONTH_OPTIONS.map((months) => ( + + ))} +
+ )} +

+ {purgeScope === "all" + ? "Tous les articles de veille seront supprimés." + : `Les articles publiés, ou importés sans date de publication, il y a plus de ${purgeMonths} mois seront supprimés.`} +

Cette opération est irréversible.

@@ -394,10 +444,14 @@ export default function VeilleDashboard() { Annuler purgeMutation.mutate()} + onClick={() => purgeMutation.mutate( + purgeScope === "all" + ? { scope: "all" } + : { scope: "older_than", months: purgeMonths }, + )} disabled={purgeMutation.isPending} > - {purgeMutation.isPending ? "Purge en cours..." : "Oui, purger toutes les données"} + {purgeMutation.isPending ? "Purge en cours..." : "Oui, supprimer définitivement"} diff --git a/server/db.ts b/server/db.ts index 6d7f5e0..e9d1a00 100644 --- a/server/db.ts +++ b/server/db.ts @@ -684,3 +684,52 @@ export async function purgeAapItems(): Promise { const result = await db.delete(aapItems); return (result as any).affectedRows ?? 0; } + +/** Calcule une date limite calendaire sans approximer un mois par un nombre de jours. */ +function cutoffForRetentionMonths(months: number): Date { + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - months); + return cutoff; +} + +/** + * Supprime les articles de veille dont la date de publication est trop ancienne. + * Pour les imports dont la source ne fournit pas cette date, importedAt garantit + * une purge prévisible plutôt que de conserver indéfiniment l'article. + */ +export async function purgeVeilleItemsOlderThan(months: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const cutoff = cutoffForRetentionMonths(months); + const where = sql`COALESCE(${veilleItems.datePublication}, ${veilleItems.importedAt}) <= ${cutoff}`; + const items = await db.select({ id: veilleItems.id }).from(veilleItems).where(where); + await removeArticleReadRecords("veille", items.map((item: { id: number }) => item.id)); + const result = await db.delete(veilleItems).where(where); + return (result as any).affectedRows ?? 0; +} + +/** Supprime les AAP clôturés dont la date de clôture est connue. */ +export async function purgeExpiredAapItems(): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const where = lte(aapItems.dateCloture, new Date()); + const items = await db.select({ id: aapItems.id }).from(aapItems).where(where); + await removeArticleReadRecords("aap", items.map((item: { id: number }) => item.id)); + const result = await db.delete(aapItems).where(where); + return (result as any).affectedRows ?? 0; +} + +/** Applique aux AAP la même règle d'ancienneté que pour la veille stratégique. */ +export async function purgeAapItemsOlderThan(months: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const cutoff = cutoffForRetentionMonths(months); + const where = sql`COALESCE(${aapItems.datePublication}, ${aapItems.importedAt}) <= ${cutoff}`; + const items = await db.select({ id: aapItems.id }).from(aapItems).where(where); + await removeArticleReadRecords("aap", items.map((item: { id: number }) => item.id)); + const result = await db.delete(aapItems).where(where); + return (result as any).affectedRows ?? 0; +} diff --git a/server/routers.ts b/server/routers.ts index 856703e..c82c79a 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -11,6 +11,9 @@ import { getAapDistinctValues, purgeVeilleItems, purgeAapItems, + purgeVeilleItemsOlderThan, + purgeExpiredAapItems, + purgeAapItemsOlderThan, getAllSettings, setSettings, getImportLogs, @@ -56,6 +59,28 @@ const adminProcedure = protectedProcedure.use(({ ctx, next }) => { return next({ ctx }); }); +const retentionMonths = z.union([ + z.literal(1), z.literal(3), z.literal(6), z.literal(12), z.literal(18), z.literal(24), +]); + +const veillePurgeInput = z.object({ + scope: z.enum(["all", "older_than"]), + months: retentionMonths.optional(), +}).superRefine((value, ctx) => { + if (value.scope === "older_than" && value.months === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "La durée de conservation est requise." }); + } +}); + +const aapPurgeInput = z.object({ + scope: z.enum(["all", "expired", "older_than"]), + months: retentionMonths.optional(), +}).superRefine((value, ctx) => { + if (value.scope === "older_than" && value.months === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "La durée de conservation est requise." }); + } +}); + // ─── Router principal ───────────────────────────────────────────────────────── export const appRouter = router({ @@ -127,10 +152,14 @@ export const appRouter = router({ filters: publicProcedure.query(async () => { return getVeilleDistinctValues(); }), - purge: adminProcedure.mutation(async () => { - const count = await purgeVeilleItems(); - return { success: true, deleted: count }; - }), + purge: adminProcedure + .input(veillePurgeInput) + .mutation(async ({ input }) => { + const count = input.scope === "all" + ? await purgeVeilleItems() + : await purgeVeilleItemsOlderThan(input.months!); + return { success: true, deleted: count, scope: input.scope, months: input.months ?? null }; + }), reclassifyWithAI: adminProcedure .input(z.object({ limit: z.number().int().positive().max(200).default(50) })) @@ -238,10 +267,16 @@ export const appRouter = router({ filters: publicProcedure.query(async () => { return getAapDistinctValues(); }), - purge: adminProcedure.mutation(async () => { - const count = await purgeAapItems(); - return { success: true, deleted: count }; - }), + purge: adminProcedure + .input(aapPurgeInput) + .mutation(async ({ input }) => { + const count = input.scope === "all" + ? await purgeAapItems() + : input.scope === "expired" + ? await purgeExpiredAapItems() + : await purgeAapItemsOlderThan(input.months!); + return { success: true, deleted: count, scope: input.scope, months: input.months ?? null }; + }), reclassifyWithAI: adminProcedure .input(z.object({ limit: z.number().int().positive().max(200).default(50) })) diff --git a/server/veille.test.ts b/server/veille.test.ts index 1c66535..cc80a1e 100644 --- a/server/veille.test.ts +++ b/server/veille.test.ts @@ -10,11 +10,23 @@ vi.mock("./db", async () => { getVeilleDistinctValues: vi.fn(async () => ({ categories: [], niveaux: [], territoires: [] })), getAapItems: vi.fn(async () => ({ items: [], total: 0 })), getAapDistinctValues: vi.fn(async () => ({ regions: [], departements: [] })), + purgeVeilleItems: vi.fn(async () => 12), + purgeVeilleItemsOlderThan: vi.fn(async () => 4), + purgeAapItems: vi.fn(async () => 9), + purgeExpiredAapItems: vi.fn(async () => 3), + purgeAapItemsOlderThan: vi.fn(async () => 2), }; }); import { appRouter } from "./routers"; import type { TrpcContext } from "./_core/context"; +import { + purgeAapItems, + purgeAapItemsOlderThan, + purgeExpiredAapItems, + purgeVeilleItems, + purgeVeilleItemsOlderThan, +} from "./db"; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -128,6 +140,36 @@ describe("protection admin", () => { const caller = appRouter.createCaller(ctx); await expect(caller.rss.classificationErrors({ page: 1, pageSize: 25 })).rejects.toThrow(); }); + + it("refuse les purges ciblées pour un non admin", async () => { + const caller = appRouter.createCaller(makeUserCtx()); + await expect(caller.veille.purge({ scope: "older_than", months: 6 })).rejects.toThrow(); + await expect(caller.aap.purge({ scope: "expired" })).rejects.toThrow(); + }); +}); + +describe("purges administrateur", () => { + beforeEach(() => vi.clearAllMocks()); + + it("applique la durée choisie à la veille", async () => { + const result = await appRouter.createCaller(makeAdminCtx()).veille.purge({ scope: "older_than", months: 6 }); + expect(purgeVeilleItemsOlderThan).toHaveBeenCalledWith(6); + expect(purgeVeilleItems).not.toHaveBeenCalled(); + expect(result).toMatchObject({ success: true, deleted: 4, scope: "older_than", months: 6 }); + }); + + it("supprime uniquement les AAP clôturés lorsque demandé", async () => { + const result = await appRouter.createCaller(makeAdminCtx()).aap.purge({ scope: "expired" }); + expect(purgeExpiredAapItems).toHaveBeenCalledOnce(); + expect(purgeAapItems).not.toHaveBeenCalled(); + expect(purgeAapItemsOlderThan).not.toHaveBeenCalled(); + expect(result).toMatchObject({ success: true, deleted: 3, scope: "expired", months: null }); + }); + + it("refuse une purge par ancienneté sans durée", async () => { + const caller = appRouter.createCaller(makeAdminCtx()); + await expect(caller.aap.purge({ scope: "older_than" } as never)).rejects.toThrow(); + }); }); // ─── Tests accès public ───────────────────────────────────────────────────────