Checkpoint: Audit et refactoring : centralisation robuste du suivi lu/non lu, suppression des captures SQL silencieuses, migration d’unicité avec déduplication des lectures existantes, correction du reclassement AAP via classifyAap, nettoyage de cinq composants template non utilisés, nettoyage des lectures lors des purges/fusions RSS et découpage dynamique du frontend. 27 tests Vitest, TypeScript et build de production validés.

This commit is contained in:
Manus
2026-08-17 20:21:45 +00:00
parent 1344d3c060
commit e4a389cc67
18 changed files with 1343 additions and 2348 deletions

View File

@@ -31,15 +31,19 @@ import {
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 { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
import { classifyAap, classifyArticle } from "./aiClassifier";
import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
// ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -148,6 +152,7 @@ export const appRouter = router({
);
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) {
@@ -176,64 +181,31 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu ─────────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Insérer seulement si pas déjà lu (ignore le doublon)
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "veille",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
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" });
// Récupérer tous les IDs veille
// 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 allIds = allItems.map((r: { id: number }) => r.id);
// Trouver ceux déjà lus
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "veille" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
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) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
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 }) => {
const db = await getDb();
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
return { ids: await getReadArticleIds(ctx.user.id, "veille") };
}),
}),
// ─── AAPP ────────────────────────────────────────────────────────────────────
@@ -283,12 +255,13 @@ export const appRouter = router({
for (const row of rows) {
try {
const aiResult = await classifyArticle(
const aiResult = await classifyAap(
row.titre || "",
row.resume || "",
() => ({ typeVeille: "informationnelle" as const })
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)
@@ -300,7 +273,9 @@ export const appRouter = router({
await db
.update(aapItems)
.set({
categorie: aiResult.categorieAap ?? row.categorie,
iaRelevant: aiResult.relevant,
iaCategorie: aiResult.categorieAap,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
@@ -315,61 +290,30 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu AAP ───────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "aap",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
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 allIds = allItems.map((r: { id: number }) => r.id);
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "aap" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
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) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(aapItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
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 }) => {
const db = await getDb();
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
return { ids: await getReadArticleIds(ctx.user.id, "aap") };
}),
}),
// ─── Import ─────────────────────────────────────────────────────────────────