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:
120
server/db.ts
120
server/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user