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

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { isDuplicateEntryError, persistArticleReads, readArticleIdsFromDb } from "./db";
function createReadDb(existingIds: number[] = []) {
const insertValues = vi.fn().mockResolvedValue(undefined);
const insert = vi.fn(() => ({ values: insertValues }));
const where = vi.fn().mockResolvedValue(existingIds.map((articleId) => ({ articleId })));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
return { db: { select, insert }, insertValues };
}
describe("isDuplicateEntryError", () => {
it("identifie les erreurs de contrainte unique MySQL", () => {
expect(isDuplicateEntryError({ code: "ER_DUP_ENTRY" })).toBe(true);
expect(isDuplicateEntryError({ cause: { code: "ER_DUP_ENTRY" } })).toBe(true);
});
it("ne masque jamais une erreur SQL non liée à un doublon", () => {
expect(isDuplicateEntryError(new Error("Field 'readAt' doesn't have a default value"))).toBe(false);
expect(isDuplicateEntryError({ code: "ER_NO_DEFAULT_FOR_FIELD" })).toBe(false);
expect(isDuplicateEntryError(null)).toBe(false);
});
});
describe("persistance des articles lus", () => {
it("insère uniquement les articles encore non lus et déduplique la demande", async () => {
const { db, insertValues } = createReadDb([10]);
const inserted = await persistArticleReads(db, 2, "veille", [10, 12, 12, 13]);
expect(inserted).toBe(2);
expect(insertValues).toHaveBeenCalledWith([
{ userId: 2, articleType: "veille", articleId: 12 },
{ userId: 2, articleType: "veille", articleId: 13 },
]);
});
it("restaure les identifiants lus stockés pour le bon utilisateur et le bon flux", async () => {
const { db } = createReadDb([4, 9]);
await expect(readArticleIdsFromDb(db, 2, "aap")).resolves.toEqual([4, 9]);
});
});

View File

@@ -1,4 +1,4 @@
import { eq, desc, and, like, gte, lte, or, sql } from "drizzle-orm";
import { count, desc, and, eq, inArray, like, gte, lte, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
@@ -15,6 +15,7 @@ import {
rssFeeds,
rssSettings,
processedDedupKeys,
articleReads,
type InsertRssFeed,
type InsertRssSettings,
type ImportLog,
@@ -44,6 +45,114 @@ export async function getDb() {
return _db;
}
// ─── Lectures d'articles ────────────────────────────────────────────────────
/** Les deux collections suivies par le marquage lu/non lu. */
export type ArticleReadType = "veille" | "aap";
/**
* Un doublon est attendu lorsqu'un utilisateur rouvre rapidement le même article.
* Toute autre erreur SQL doit rester visible afin de ne jamais perdre une lecture
* silencieusement, comme cela s'était produit avec une colonne readAt invalide.
*/
export function isDuplicateEntryError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const databaseError = error as { code?: unknown; cause?: unknown };
const cause = databaseError.cause as { code?: unknown } | undefined;
return databaseError.code === "ER_DUP_ENTRY" || cause?.code === "ER_DUP_ENTRY";
}
/** Supprime les marqueurs de lecture devenus orphelins après suppression d'articles. */
export async function removeArticleReadRecords(articleType: ArticleReadType, articleIds: number[]): Promise<void> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return;
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(and(
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
}
/**
* Insère les lectures encore absentes pour un utilisateur.
* L'unicité (userId, articleType, articleId) est garantie par le schéma SQL ; le
* pré-filtrage évite néanmoins une écriture inutile sur chaque ouverture de détail.
*/
export async function markArticlesAsRead(
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return persistArticleReads(db, userId, articleType, articleIds);
}
/**
* Cœur testable de l'écriture des lectures. Le routeur ne lui transmet qu'une
* connexion Drizzle déjà ouverte ; aucune règle métier ne dépend de l'interface HTTP.
*/
export async function persistArticleReads(
db: any,
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return 0;
const existingReads = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(
eq(articleReads.userId, userId),
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
const existingIds = new Set(existingReads.map((read: { articleId: number }) => read.articleId));
const unreadIds = uniqueIds.filter((articleId) => !existingIds.has(articleId));
if (unreadIds.length === 0) return 0;
try {
await db.insert(articleReads).values(unreadIds.map((articleId) => ({ userId, articleType, articleId })));
return unreadIds.length;
} catch (error) {
if (isDuplicateEntryError(error)) return 0;
throw error;
}
}
/** Retourne les identifiants lus, utilisés pour restaurer l'état utilisateur après connexion. */
export async function getReadArticleIds(userId: number, articleType: ArticleReadType): Promise<number[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return readArticleIdsFromDb(db, userId, articleType);
}
/** Cœur testable de la restitution de l'état lu/non lu après reconnexion. */
export async function readArticleIdsFromDb(db: any, userId: number, articleType: ArticleReadType): Promise<number[]> {
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return rows.map((read: { articleId: number }) => read.articleId);
}
/** Calcule le nombre d'articles non lus à partir d'un total métier fourni par le routeur. */
export async function getUnreadArticleCount(userId: number, articleType: ArticleReadType, totalItems: number): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const rows = await db
.select({ total: count() })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return Math.max(0, totalItems - (rows[0]?.total ?? 0));
}
// ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
@@ -495,6 +604,13 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
if (!db) throw new Error("Database not available");
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - retentionMonths);
// Les marqueurs de lecture ne doivent jamais survivre à l'article auquel ils se rapportent.
const oldVeilleItems = await db.select({ id: veilleItems.id }).from(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const oldAapItems = await db.select({ id: aapItems.id }).from(aapItems).where(lte(aapItems.importedAt, cutoff));
await removeArticleReadRecords("veille", oldVeilleItems.map((item: { id: number }) => item.id));
await removeArticleReadRecords("aap", oldAapItems.map((item: { id: number }) => item.id));
const veilleResult = await db.delete(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff));
// Purge des tombstones (processed_dedup_keys) de plus de 6 mois
@@ -511,6 +627,7 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
export async function purgeVeilleItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "veille"));
const result = await db.delete(veilleItems);
return (result as any).affectedRows ?? 0;
}
@@ -518,6 +635,7 @@ export async function purgeVeilleItems(): Promise<number> {
export async function purgeAapItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "aap"));
const result = await db.delete(aapItems);
return (result as any).affectedRows ?? 0;
}

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 ─────────────────────────────────────────────────────────────────

View File

@@ -14,7 +14,7 @@
*/
import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto";
import { getDb } from "./db";
import { getDb, removeArticleReadRecords } from "./db";
import {
rssFeeds,
veilleItems,
@@ -775,6 +775,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
} else {
@@ -817,6 +818,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Supprimer les doublons
for (const dup of duplicates) {
await removeArticleReadRecords("veille", [dup.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
veilleMerged++;
}
@@ -825,6 +827,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
}
@@ -868,6 +871,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
} else {
@@ -903,6 +907,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
.where(eq(aapItems.id, primary.id));
for (const dup of duplicates) {
await removeArticleReadRecords("aap", [dup.id]);
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
aapMerged++;
}
@@ -911,6 +916,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
}