feat: purger les contenus expirés ou anciens
This commit is contained in:
49
server/db.ts
49
server/db.ts
@@ -684,3 +684,52 @@ export async function purgeAapItems(): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user