From 8fae4391a2121b990c25bce6f6625d522b780203 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 11:11:27 +0100 Subject: [PATCH 01/19] feat: Implement database initialization script and Drizzle ORM connection setup. --- scripts/db-init.js | 8 +++++++- src/db/index.ts | 17 ++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/db-init.js b/scripts/db-init.js index 5fa73d3..5c0e16b 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -6,7 +6,7 @@ const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts async function main() { console.log("[DB Init] Starting database migration & initialization..."); - let connectionString = process.env.DATABASE_URL; + let connectionString = ""; try { const CONFIG_PATH = path.join(cwd, "data", ".db-config.json"); @@ -21,6 +21,12 @@ async function main() { console.error("[DB Init] Error reading .db-config.json", e); } + // Force environment variable if present (for Docker/DevOps consistency) + if (process.env.DATABASE_URL) { + connectionString = process.env.DATABASE_URL; + console.log("[DB Init] Using environment variable DATABASE_URL (Priority)."); + } + if (!connectionString) { console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push."); process.exit(0); diff --git a/src/db/index.ts b/src/db/index.ts index fc8ce66..0c5266e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -18,10 +18,7 @@ function maskUrl(url: string | undefined) { export function getDb() { if (currentDb) return currentDb; - let connectionString = process.env.DATABASE_URL; - - console.log("[DB] Initializing connection..."); - console.log("[DB] Env DATABASE_URL:", maskUrl(process.env.DATABASE_URL)); + let connectionString = ""; try { const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); @@ -30,15 +27,21 @@ export function getDb() { const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); if (config.url) { connectionString = config.url; - console.log("[DB] Using saved database URL:", maskUrl(connectionString)); + console.log("[DB] Loaded saved database URL from config."); } - } else { - console.log("[DB] No saved config file found. Using environment variable."); } } catch (err) { console.error("[DB] Error reading config file:", err); } + // IMPORTANT: Environment variable MUST take precedence in container environments + if (process.env.DATABASE_URL) { + connectionString = process.env.DATABASE_URL; + console.log("[DB] Using environment variable (優先度高)."); + } + + console.log("[DB] Initializing connection with:", maskUrl(connectionString)); + if (!connectionString) { console.error("[DB] CRITICAL: No database connection string available!"); } From 3ce22defa76ed470c11334a4f9b93f23745096f5 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 11:29:39 +0100 Subject: [PATCH 02/19] feat: Introduce product scoring based on weighted sales metrics and store participation, integrated into the product grid API, and add a performance test script. --- scripts/repro-500.js | 39 +++ src/features/grid/api/get-product-rows.ts | 281 +++++++++++----------- src/lib/score-engine.ts | 6 +- 3 files changed, 185 insertions(+), 141 deletions(-) create mode 100644 scripts/repro-500.js diff --git a/scripts/repro-500.js b/scripts/repro-500.js new file mode 100644 index 0000000..59b38df --- /dev/null +++ b/scripts/repro-500.js @@ -0,0 +1,39 @@ +const { computeProductScores } = require('./src/lib/score-engine'); + +// Simulate 50,000 rows +const largeDataset = Array.from({ length: 50000 }, (_, i) => ({ + codein: `item-${i}`, + codeFournisseur: 'A032', + nomFournisseur: 'ACCESS DECO', + libelle1: `Product ${i}`, + gtin: '123456789', + reference: 'REF', + code1: '20', + libelleNiveau1: 'LIQUIDES', + code2: '2001', + libelleNiveau2: 'EAUX', + code3: '200101', + libelle3: 'EAU SOURCE', + codeGamme: 'A', + codeGammeInit: 'A', + codeGammeDraft: null, + sales12m: {}, + totalQuantite: Math.random() * 1000, + totalCa: Math.random() * 5000, + totalMarge: Math.random() * 1000, + tauxMarge: 20, + score: 0, + workingStores: ['MAG1', 'MAG2'] +})); + +console.log(`Starting test with ${largeDataset.length} rows...`); + +try { + const start = Date.now(); + const result = computeProductScores(largeDataset); + const end = Date.now(); + console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`); +} catch (error) { + console.error('Failed!', error); + process.exit(1); +} diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 9f5888e..b3bfad5 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -17,145 +17,150 @@ interface GetProductRowsInput { export async function getProductRows(input: GetProductRowsInput): Promise { const { codeFournisseur, magasin = "TOTAL", filters } = input; - // Self-healing: Ensure codeGammeInit column exists and is populated - if (!isSchemaInitialized) { - try { - await db.execute(sql` - ALTER TABLE ventes_produits - ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20) - `); - await db.execute(sql` - UPDATE ventes_produits - SET code_gamme_init = code_gamme - WHERE code_gamme_init IS NULL - `); - isSchemaInitialized = true; - console.log("[Schema] codeGammeInit initialized successfully."); - } catch (err) { - console.error("[Schema] Failed to initialize codeGammeInit:", err); - // We continue anyway, the mapping fallback will handle it for the UI - } - } - - // ... (allowedPeriods calculation remains the same) - const now = new Date(); - const allowedPeriods = new Set(); - for (let i = 12; i >= 0; i--) { // i >= 0 au lieu de 1 pour inclure le mois en cours - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - - // Build conditions - const conditions = [ - eq(ventesProduits.codeFournisseur, codeFournisseur), - ne(ventesProduits.periode, "TOTAL"), - magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"), - ]; - - // Hierarchical filtering - if (filters?.code3) { - conditions.push(eq(ventesProduits.code3, filters.code3)); - } else if (filters?.code2) { - conditions.push(like(ventesProduits.code3, `${filters.code2}%`)); - } else if (filters?.code1) { - conditions.push(like(ventesProduits.code3, `${filters.code1}%`)); - } - - // Get all rows for this supplier - const rows = await db - .select() - .from(ventesProduits) - .where(and(...conditions)); - - // Group by codein and aggregate monthly sales + track store participation - const productMap = new Map(); - const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods - - for (const row of rows) { - // Ignorer les données qui ne font pas partie des 12 mois complets - if (!allowedPeriods.has(row.periode)) continue; - - const { codein, magasin, periode } = row; - - // Suivi de la participation par magasin - if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map()); - const productStores = storeParticipationMap.get(codein)!; - if (!productStores.has(magasin)) productStores.set(magasin, new Set()); - productStores.get(magasin)!.add(periode); - - const existing = productMap.get(codein); - const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0")); - const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0")); - const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0")); - - if (existing) { - existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty; - existing.totalQuantite += qty; - existing.totalCa += ca; - existing.totalMarge += marge; - existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0; - } else { - const c3 = row.code3 || ""; - const c1 = c3.slice(0, 2); - const c2 = c3.slice(0, 4); - - const NOMENCLATURE_LABELS: Record = { - "20": "LIQUIDES", - "30": "ÉPICERIE", - "40": "DPH", - "50": "BAZAR", - "60": "TEXTILE", - "70": "FRAIS", - // Rayons - "2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS", - "3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES", - "4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE", - "5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN", - "7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE", - }; - - productMap.set(codein, { - codein, - codeFournisseur: row.codeFournisseur ?? "", - nomFournisseur: row.nomFournisseur ?? "", - libelle1: row.libelle1 ?? "", - gtin: row.gtin ?? "", - reference: row.reference ?? "", - code1: c1, - libelleNiveau1: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`, - code2: c2, - libelleNiveau2: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`, - code3: c3, - libelle3: row.libelle3 ?? "", - codeGamme: (row.codeGamme as GammeCode | null), - codeGammeInit: (row.codeGammeInit as GammeCode | null) ?? (row.codeGamme as GammeCode | null), - codeGammeDraft: null, - sales12m: { [periode]: qty }, - totalQuantite: qty, - totalCa: ca, - totalMarge: marge, - tauxMarge: ca > 0 ? (marge / ca) * 100 : 0, - score: 0, - workingStores: [], - aiRecommendation: null, - }); - } - } - - // Finaliser la liste des magasins "actifs" (travaillant le produit : >= 3 mois de vente) - for (const [codein, product] of productMap.entries()) { - const productStores = storeParticipationMap.get(codein); - if (productStores) { - const workingStores: string[] = []; - for (const [magasin, periods] of productStores.entries()) { - if (magasin === "TOTAL") continue; - if (periods.size >= 1) { - workingStores.push(magasin); - } + try { + // Self-healing: Ensure codeGammeInit column exists and is populated + if (!isSchemaInitialized) { + try { + await db.execute(sql` + ALTER TABLE ventes_produits + ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20) + `); + await db.execute(sql` + UPDATE ventes_produits + SET code_gamme_init = code_gamme + WHERE code_gamme_init IS NULL + `); + isSchemaInitialized = true; + console.log("[Schema] codeGammeInit initialized successfully."); + } catch (err) { + console.error("[Schema] Failed to initialize codeGammeInit:", err); + // We continue anyway, the mapping fallback will handle it for the UI } - product.workingStores = workingStores.sort(); } - } - return computeProductScores(Array.from(productMap.values())); + // ... (allowedPeriods calculation remains the same) + const now = new Date(); + const allowedPeriods = new Set(); + for (let i = 12; i >= 0; i--) { // i >= 0 au lieu de 1 pour inclure le mois en cours + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); + } + + // Build conditions + const conditions = [ + eq(ventesProduits.codeFournisseur, codeFournisseur), + ne(ventesProduits.periode, "TOTAL"), + magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"), + ]; + + // Hierarchical filtering + if (filters?.code3) { + conditions.push(eq(ventesProduits.code3, filters.code3)); + } else if (filters?.code2) { + conditions.push(like(ventesProduits.code3, `${filters.code2}%`)); + } else if (filters?.code1) { + conditions.push(like(ventesProduits.code3, `${filters.code1}%`)); + } + + // Get all rows for this supplier + const rows = await db + .select() + .from(ventesProduits) + .where(and(...conditions)); + + // Group by codein and aggregate monthly sales + track store participation + const productMap = new Map(); + const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods + + for (const row of rows) { + // Ignorer les données qui ne font pas partie des 12 mois complets + if (!allowedPeriods.has(row.periode)) continue; + + const { codein, magasin, periode } = row; + + // Suivi de la participation par magasin + if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map()); + const productStores = storeParticipationMap.get(codein)!; + if (!productStores.has(magasin)) productStores.set(magasin, new Set()); + productStores.get(magasin)!.add(periode); + + const existing = productMap.get(codein); + const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0")); + const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0")); + const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0")); + + if (existing) { + existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty; + existing.totalQuantite += qty; + existing.totalCa += ca; + existing.totalMarge += marge; + existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0; + } else { + const c3 = row.code3 || ""; + const c1 = c3.slice(0, 2); + const c2 = c3.slice(0, 4); + + const NOMENCLATURE_LABELS: Record = { + "20": "LIQUIDES", + "30": "ÉPICERIE", + "40": "DPH", + "50": "BAZAR", + "60": "TEXTILE", + "70": "FRAIS", + // Rayons + "2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS", + "3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES", + "4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE", + "5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN", + "7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE", + }; + + productMap.set(codein, { + codein, + codeFournisseur: row.codeFournisseur ?? "", + nomFournisseur: row.nomFournisseur ?? "", + libelle1: row.libelle1 ?? "", + gtin: row.gtin ?? "", + reference: row.reference ?? "", + code1: c1, + libelleNiveau1: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`, + code2: c2, + libelleNiveau2: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`, + code3: c3, + libelle3: row.libelle3 ?? "", + codeGamme: (row.codeGamme as GammeCode | null), + codeGammeInit: (row.codeGammeInit as GammeCode | null) ?? (row.codeGamme as GammeCode | null), + codeGammeDraft: null, + sales12m: { [periode]: qty }, + totalQuantite: qty, + totalCa: ca, + totalMarge: marge, + tauxMarge: ca > 0 ? (marge / ca) * 100 : 0, + score: 0, + workingStores: [], + aiRecommendation: null, + }); + } + } + + // Finaliser la liste des magasins "actifs" (travaillant le produit : >= 3 mois de vente) + for (const [codein, product] of productMap.entries()) { + const productStores = storeParticipationMap.get(codein); + if (productStores) { + const workingStores: string[] = []; + for (const [magasin, periods] of productStores.entries()) { + if (magasin === "TOTAL") continue; + if (periods.size >= 3) { + workingStores.push(magasin); + } + } + product.workingStores = workingStores.sort(); + } + } + + return computeProductScores(Array.from(productMap.values())); + } catch (error) { + console.error(`[getProductRows] Error for supplier ${codeFournisseur}:`, error); + return []; + } } diff --git a/src/lib/score-engine.ts b/src/lib/score-engine.ts index 6c88761..6a04657 100644 --- a/src/lib/score-engine.ts +++ b/src/lib/score-engine.ts @@ -46,9 +46,9 @@ export function computeProductScores( }; }); - const maxQty = Math.max(...weightedData.map((d) => d.wQty)); - const maxCa = Math.max(...weightedData.map((d) => d.wCa)); - const maxMarge = Math.max(...weightedData.map((d) => d.wMarge)); + const maxQty = weightedData.reduce((max, d) => Math.max(max, d.wQty), 0); + const maxCa = weightedData.reduce((max, d) => Math.max(max, d.wCa), 0); + const maxMarge = weightedData.reduce((max, d) => Math.max(max, d.wMarge), 0); // 2. Calculer les moyennes et totaux globaux const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0); From 47872a994f47fb580f18d9e022ec9bbcc7949ec7 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 11:38:12 +0100 Subject: [PATCH 03/19] feat: Introduce NextAuth.js for authentication, including default admin seeding and user table auto-creation. --- src/features/auth/logic/seed-admin.ts | 9 ++++++--- src/lib/auth.ts | 22 +++++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts index a7863f2..0827dbc 100644 --- a/src/features/auth/logic/seed-admin.ts +++ b/src/features/auth/logic/seed-admin.ts @@ -28,16 +28,19 @@ export async function ensureAdminExists() { .limit(1); if (adminUser.length === 0) { - console.log("BMAD: Default admin account not found. Initializing (admin/admin)..."); + console.log("[AUTH] Admin user not found. Seeding default (admin/admin)..."); await db.insert(users).values({ username: "admin", passwordHash: hashPassword("admin"), role: "admin" }); + console.log("[AUTH] Default admin seeded successfully."); return true; + } else { + console.log("[AUTH] Admin user already exists."); } - } catch (err) { - console.error("BMAD: CRITICAL Error checking/seeding users table.", err); + } catch (err: any) { + console.error("[AUTH] CRITICAL Error during ensureAdminExists:", err.message || err); } return false; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index be1a903..fbbcf5c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -22,26 +22,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (!credentials?.username || !credentials?.password) return null; try { + console.log(`[AUTH] Checking user "${credentials.username}" in database...`); const [user] = await db.select() .from(users) .where(eq(users.username, credentials.username as string)); - console.log(`BMAD: Auth attempt for "${credentials.username}". User found: ${!!user}`); + console.log(`[AUTH] User found in DB: ${!!user}`); - if (!user) return null; + if (!user) { + console.warn(`[AUTH] User "${credentials.username}" not found.`); + return null; + } const isValid = verifyPassword(credentials.password as string, user.passwordHash); - console.log(`BMAD: Password valid for "${credentials.username}": ${isValid}`); + console.log(`[AUTH] Password valid for "${credentials.username}": ${isValid}`); - if (!isValid) return null; + if (!isValid) { + console.warn(`[AUTH] Invalid password for user "${credentials.username}".`); + return null; + } return { id: user.id.toString(), - name: user.username, + username: user.username, role: user.role, }; - } catch (err) { - console.error("Auth Error:", err); + } catch (err: any) { + console.error("[AUTH] CRITICAL ERROR during authorize callback:", err.message || err); + // Propage l'erreur pour que Next-Auth la gère (mais authorize doit retourner null ou l'utilisateur) return null; } } From 086da9dfde37ff655058016bbd6487b9b515dcbf Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 11:41:44 +0100 Subject: [PATCH 04/19] feat: Add database initialization script and default admin user seeding. --- scripts/db-init.js | 11 +++++++++++ src/features/auth/logic/seed-admin.ts | 13 +------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/db-init.js b/scripts/db-init.js index 5c0e16b..53a6eda 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -63,6 +63,17 @@ async function main() { `); console.log("[DB Init] Table session_snapshots is verified/created."); + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" varchar(50) NOT NULL UNIQUE, + "password_hash" text NOT NULL, + "role" varchar(20) NOT NULL DEFAULT 'user', + "created_at" timestamp DEFAULT now() + ); + `); + console.log("[DB Init] Table users is verified/created."); + await tempPool.end(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts index 0827dbc..5fe79f1 100644 --- a/src/features/auth/logic/seed-admin.ts +++ b/src/features/auth/logic/seed-admin.ts @@ -10,18 +10,7 @@ import { count, eq, sql } from "drizzle-orm"; */ export async function ensureAdminExists() { try { - // 1. Auto-repair: Ensure table exists - await db.execute(sql` - CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role VARCHAR(20) NOT NULL DEFAULT 'user', - created_at TIMESTAMP DEFAULT NOW() - ); - `); - - // 2. Check if admin specifically exists + // 1. Check if admin specifically exists const adminUser = await db.select() .from(users) .where(eq(users.username, "admin")) From 4819ad774361b12366e1db84499fec0b12ce1b70 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:03:18 +0100 Subject: [PATCH 05/19] feat: Introduce `ensureAdminExists` function to seed a default admin user and create the users table if needed. --- src/features/auth/logic/seed-admin.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts index 5fe79f1..6ae0e1b 100644 --- a/src/features/auth/logic/seed-admin.ts +++ b/src/features/auth/logic/seed-admin.ts @@ -10,7 +10,18 @@ import { count, eq, sql } from "drizzle-orm"; */ export async function ensureAdminExists() { try { - // 1. Check if admin specifically exists + // 1. Auto-repair: Ensure table exists (Fallback if db-init failed) + // Note: Using raw SQL to avoid Drizzle mapping issues during initialization + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "users" ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT NOW() + ); + `); + console.log("[AUTH] users table verified/created."); const adminUser = await db.select() .from(users) .where(eq(users.username, "admin")) From 4e80f228fee1ad05366bc62321d644eaf66a55f6 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:13:57 +0100 Subject: [PATCH 06/19] feat: Seed default admin user and ensure users table existence. --- src/features/auth/logic/seed-admin.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts index 6ae0e1b..4e48e2a 100644 --- a/src/features/auth/logic/seed-admin.ts +++ b/src/features/auth/logic/seed-admin.ts @@ -10,8 +10,12 @@ import { count, eq, sql } from "drizzle-orm"; */ export async function ensureAdminExists() { try { + console.log("[AUTH] Testing database connection..."); + await db.execute(sql`SELECT 1`); + console.log("[AUTH] Database connection OK."); + // 1. Auto-repair: Ensure table exists (Fallback if db-init failed) - // Note: Using raw SQL to avoid Drizzle mapping issues during initialization + console.log("[AUTH] Checking/Creating users table..."); await db.execute(sql` CREATE TABLE IF NOT EXISTS "users" ( id SERIAL PRIMARY KEY, @@ -22,6 +26,7 @@ export async function ensureAdminExists() { ); `); console.log("[AUTH] users table verified/created."); + const adminUser = await db.select() .from(users) .where(eq(users.username, "admin")) @@ -40,7 +45,12 @@ export async function ensureAdminExists() { console.log("[AUTH] Admin user already exists."); } } catch (err: any) { - console.error("[AUTH] CRITICAL Error during ensureAdminExists:", err.message || err); + console.error("[AUTH] !!! CRITICAL DATABASE ERROR !!!"); + console.error("- Message:", err.message); + console.error("- PG Code:", err.code); + console.error("- Detail:", err.detail); + console.error("- Hint:", err.hint); + if (err.internalQuery) console.error("- Query:", err.internalQuery); } return false; } From 69e2f9c3253a32d677a83b8a8b139451292f43dd Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:24:30 +0100 Subject: [PATCH 07/19] feat: Implement NextAuth authentication with credentials, database and JSON fallback, including admin seeding and password management. --- src/features/auth/logic/auth-logic.ts | 48 +++++++++++++++++++++++++++ src/features/auth/logic/seed-admin.ts | 38 +++++++++++---------- src/lib/auth.ts | 30 ++++++++++++----- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/features/auth/logic/auth-logic.ts b/src/features/auth/logic/auth-logic.ts index 2cfff19..100b79c 100644 --- a/src/features/auth/logic/auth-logic.ts +++ b/src/features/auth/logic/auth-logic.ts @@ -1,4 +1,15 @@ import { scryptSync, randomBytes, timingSafeEqual } from "crypto"; +import fs from "fs"; +import path from "path"; + +const USERS_JSON_PATH = path.join(process.cwd(), "data", "users.json"); + +export interface UserJson { + id: string; + username: string; + passwordHash: string; + role: string; +} /** * Hashes a password using scrypt. @@ -26,3 +37,40 @@ export function verifyPassword(password: string, storedHash: string): boolean { return false; } } + +/** + * Reads users from local JSON fallback. + */ +export function getFallbackUsers(): UserJson[] { + try { + if (!fs.existsSync(USERS_JSON_PATH)) { + // Initial seed if file doesn't exist + const defaultAdmin: UserJson = { + id: "0", + username: "admin", + passwordHash: hashPassword("admin"), + role: "admin" + }; + saveFallbackUsers([defaultAdmin]); + return [defaultAdmin]; + } + const data = fs.readFileSync(USERS_JSON_PATH, "utf-8"); + return JSON.parse(data); + } catch (e) { + console.error("[AUTH] Error reading users.json:", e); + return []; + } +} + +/** + * Saves users to local JSON fallback. + */ +export function saveFallbackUsers(users: UserJson[]) { + try { + const dir = path.dirname(USERS_JSON_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(USERS_JSON_PATH, JSON.stringify(users, null, 2)); + } catch (e) { + console.error("[AUTH] Error writing users.json:", e); + } +} diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts index 4e48e2a..fee76dd 100644 --- a/src/features/auth/logic/seed-admin.ts +++ b/src/features/auth/logic/seed-admin.ts @@ -1,6 +1,6 @@ import { db } from "@/db"; import { users } from "@/db/schema"; -import { hashPassword } from "./auth-logic"; +import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic"; import { count, eq, sql } from "drizzle-orm"; /** @@ -9,13 +9,23 @@ import { count, eq, sql } from "drizzle-orm"; * Also ensures the table exists (auto-repair). */ export async function ensureAdminExists() { - try { - console.log("[AUTH] Testing database connection..."); - await db.execute(sql`SELECT 1`); - console.log("[AUTH] Database connection OK."); + // 1. Always ensure local JSON fallback has at least one admin + const fallbackUsers = getFallbackUsers(); + if (fallbackUsers.length === 0) { + console.log("[AUTH] Initializing local JSON fallback with admin/admin..."); + saveFallbackUsers([{ + id: "0", + username: "admin", + passwordHash: hashPassword("admin"), + role: "admin" + }]); + } - // 1. Auto-repair: Ensure table exists (Fallback if db-init failed) - console.log("[AUTH] Checking/Creating users table..."); + try { + console.log("[AUTH] Testing database connection for seeding..."); + await db.execute(sql`SELECT 1`); + + // 2. Auto-repair: Ensure table exists await db.execute(sql` CREATE TABLE IF NOT EXISTS "users" ( id SERIAL PRIMARY KEY, @@ -25,32 +35,24 @@ export async function ensureAdminExists() { created_at TIMESTAMP DEFAULT NOW() ); `); - console.log("[AUTH] users table verified/created."); + // 3. Sync admin to DB const adminUser = await db.select() .from(users) .where(eq(users.username, "admin")) .limit(1); if (adminUser.length === 0) { - console.log("[AUTH] Admin user not found. Seeding default (admin/admin)..."); + console.log("[AUTH] Seeding admin to DB..."); await db.insert(users).values({ username: "admin", passwordHash: hashPassword("admin"), role: "admin" }); - console.log("[AUTH] Default admin seeded successfully."); return true; - } else { - console.log("[AUTH] Admin user already exists."); } } catch (err: any) { - console.error("[AUTH] !!! CRITICAL DATABASE ERROR !!!"); - console.error("- Message:", err.message); - console.error("- PG Code:", err.code); - console.error("- Detail:", err.detail); - console.error("- Hint:", err.hint); - if (err.internalQuery) console.error("- Query:", err.internalQuery); + console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message); } return false; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fbbcf5c..f82464b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,7 +3,7 @@ import Credentials from "next-auth/providers/credentials"; import { db } from "@/db"; import { users } from "@/db/schema"; import { eq } from "drizzle-orm"; -import { verifyPassword } from "@/features/auth/logic/auth-logic"; +import { verifyPassword, getFallbackUsers } from "@/features/auth/logic/auth-logic"; import { ensureAdminExists } from "@/features/auth/logic/seed-admin"; import { authConfig } from "./auth.config"; @@ -22,15 +22,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (!credentials?.username || !credentials?.password) return null; try { - console.log(`[AUTH] Checking user "${credentials.username}" in database...`); - const [user] = await db.select() - .from(users) - .where(eq(users.username, credentials.username as string)); + console.log(`[AUTH] Checking user "${credentials.username}"...`); + + let user: any = null; - console.log(`[AUTH] User found in DB: ${!!user}`); + // Try DB first + try { + const [dbUser] = await db.select() + .from(users) + .where(eq(users.username, credentials.username as string)); + user = dbUser; + console.log(`[AUTH] DB check: User found: ${!!user}`); + } catch (dbErr) { + console.warn("[AUTH] DB unreachable, falling back to JSON."); + } + + // Fallback to JSON if DB failed or user not found + if (!user) { + const fallbackUsers = getFallbackUsers(); + user = fallbackUsers.find(u => u.username === credentials.username); + console.log(`[AUTH] JSON check: User found: ${!!user}`); + } if (!user) { - console.warn(`[AUTH] User "${credentials.username}" not found.`); + console.warn(`[AUTH] User "${credentials.username}" not found anywhere.`); return null; } @@ -49,7 +64,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }; } catch (err: any) { console.error("[AUTH] CRITICAL ERROR during authorize callback:", err.message || err); - // Propage l'erreur pour que Next-Auth la gère (mais authorize doit retourner null ou l'utilisateur) return null; } } From 6046be50de6e17991e095af95b58e3c295325de9 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:35:27 +0100 Subject: [PATCH 08/19] feat: Introduce Drizzle ORM for database management and a script for initial schema setup. --- scripts/db-init.js | 6 +++--- src/db/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/db-init.js b/scripts/db-init.js index 53a6eda..f844fab 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -21,10 +21,10 @@ async function main() { console.error("[DB Init] Error reading .db-config.json", e); } - // Force environment variable if present (for Docker/DevOps consistency) - if (process.env.DATABASE_URL) { + // Use environment variable as default IF NOT already loaded from config + if (!connectionString && process.env.DATABASE_URL) { connectionString = process.env.DATABASE_URL; - console.log("[DB Init] Using environment variable DATABASE_URL (Priority)."); + console.log("[DB Init] Using environment variable DATABASE_URL as fallback."); } if (!connectionString) { diff --git a/src/db/index.ts b/src/db/index.ts index 0c5266e..b7d9ff4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -34,10 +34,10 @@ export function getDb() { console.error("[DB] Error reading config file:", err); } - // IMPORTANT: Environment variable MUST take precedence in container environments - if (process.env.DATABASE_URL) { + // Use environment variable as fallback if no config is found + if (!connectionString && process.env.DATABASE_URL) { connectionString = process.env.DATABASE_URL; - console.log("[DB] Using environment variable (優先度高)."); + console.log("[DB] Using environment variable as fallback."); } console.log("[DB] Initializing connection with:", maskUrl(connectionString)); From b408de4c9cbacf76eebfcb9f355f0b4b2a2bc94a Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:47:13 +0100 Subject: [PATCH 09/19] feat: Implement API to fetch and process product sales data for grid display, including schema initialization, sales aggregation, and score calculation. --- src/features/grid/api/get-product-rows.ts | 60 +++++++++++++---------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index b3bfad5..54c3468 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -73,29 +73,21 @@ export async function getProductRows(input: GetProductRowsInput): Promise>>(); // codein -> magasin -> Set of periods for (const row of rows) { - // Ignorer les données qui ne font pas partie des 12 mois complets - if (!allowedPeriods.has(row.periode)) continue; - const { codein, magasin, periode } = row; + const isInPeriod = allowedPeriods.has(periode); - // Suivi de la participation par magasin - if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map()); - const productStores = storeParticipationMap.get(codein)!; - if (!productStores.has(magasin)) productStores.set(magasin, new Set()); - productStores.get(magasin)!.add(periode); + // Suivi de la participation par magasin (seulement si dans la période et pas TOTAL) + if (isInPeriod && magasin !== "TOTAL") { + if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map()); + const productStores = storeParticipationMap.get(codein)!; + if (!productStores.has(magasin)) productStores.set(magasin, new Set()); + productStores.get(magasin)!.add(periode); + } - const existing = productMap.get(codein); - const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0")); - const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0")); - const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0")); - - if (existing) { - existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty; - existing.totalQuantite += qty; - existing.totalCa += ca; - existing.totalMarge += marge; - existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0; - } else { + let existing = productMap.get(codein); + + // Si le produit n'existe pas encore, on l'initialise avec les métadonnées de cette ligne + if (!existing) { const c3 = row.code3 || ""; const c1 = c3.slice(0, 2); const c2 = c3.slice(0, 4); @@ -115,7 +107,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0 ? (marge / ca) * 100 : 0, + sales12m: {}, + totalQuantite: 0, + totalCa: 0, + totalMarge: 0, + tauxMarge: 0, score: 0, workingStores: [], aiRecommendation: null, - }); + }; + productMap.set(codein, existing); + } + + // On n'agrège les chiffres QUE si la période fait partie des 12 derniers mois + if (isInPeriod) { + const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0")); + const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0")); + const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0")); + + existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty; + existing.totalQuantite += qty; + existing.totalCa += ca; + existing.totalMarge += marge; + existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0; } } From f15fbe69ecba87e96921961c4948cb7a13734a54 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 12:56:37 +0100 Subject: [PATCH 10/19] feat: Implement core grid state management with Zustand, define grid data types, and introduce a heatmap cell component. --- src/features/grid/components/floating-summary-bar.tsx | 4 ++++ src/features/grid/components/heatmap-cell.tsx | 2 +- src/features/grid/store/use-grid-store.ts | 2 ++ src/types/grid.ts | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 7f201b0..010b689 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -145,6 +145,10 @@ export function FloatingSummaryBar() {
+ { const heatClass = getHeatmapClass(value); - const display = value === null || value === 0 ? "—" : Math.round(Math.abs(value)).toString(); + const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString(); return (
): const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0); const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0); return { + totalProducts: rows.length, totalRows: active.length, totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0), totalCa, @@ -65,6 +66,7 @@ export const useGridStore = create()( search: "", }, summary: { + totalProducts: 0, totalRows: 0, totalQuantite: 0, totalCa: 0, diff --git a/src/types/grid.ts b/src/types/grid.ts index 7487355..294d8b9 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -70,6 +70,7 @@ export interface ProductRow { /** Summary bar totals for the currently visible/filtered rows */ export interface GridSummary { + totalProducts: number; totalRows: number; totalQuantite: number; totalCa: number; From 08d361e980a18b3784da355fdefc483ab93e3add Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 13:37:13 +0100 Subject: [PATCH 11/19] feat: Introduce database count verification scripts and update Next.js configuration for `pg` and server action body size. --- check_counts.js | 31 +++++++++++++++++++++++++++++++ check_drizzle.ts | 19 +++++++++++++++++++ next.config.ts | 7 ++++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 check_counts.js create mode 100644 check_drizzle.ts diff --git a/check_counts.js b/check_counts.js new file mode 100644 index 0000000..a8daf4e --- /dev/null +++ b/check_counts.js @@ -0,0 +1,31 @@ + +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); +let url = ""; +if (fs.existsSync(CONFIG_PATH)) { + url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url; +} + +const pool = new Pool({ connectionString: url || process.env.DATABASE_URL }); + +async function check() { + try { + const resSuppliers = await pool.query(\`SELECT DISTINCT code_fournisseur FROM ventes_produits LIMIT 5\`); + console.log("Suppliers in DB:", resSuppliers.rows.map(r => r.code_fournisseur)); + + for (const row of resSuppliers.rows) { + const supplier = row.code_fournisseur; + const countRes = await pool.query(\`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); + const distinctCodein = await pool.query(\`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); + console.log(\`Supplier \${supplier}: Total Rows = \${countRes.rows[0].count}, Distinct Products = \${distinctCodein.rows[0].count}\`); + } + } catch (e) { + console.error("Error:", e); + } finally { + await pool.end(); + } +} +check(); diff --git a/check_drizzle.ts b/check_drizzle.ts new file mode 100644 index 0000000..44932d7 --- /dev/null +++ b/check_drizzle.ts @@ -0,0 +1,19 @@ + +import { db } from "./src/db"; +import { sql } from "drizzle-orm"; + +async function check() { + try { + const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`); + console.log("Total rows in ventes_produits:", res.rows[0].count); + + const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`); + console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count); + + const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`); + console.log("Counts per supplier:", resSupplier.rows); + } catch (e) { + console.error("Error:", e); + } +} +check(); diff --git a/next.config.ts b/next.config.ts index da249ca..8328909 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + serverExternalPackages: ["pg"], + experimental: { + serverActions: { + bodySizeLimit: "10mb", + }, + }, typescript: { - // Idem pour TypeScript ignoreBuildErrors: true, }, eslint: { From 1b09994087961e5b0c48b2c1f9014c7e7c669df9 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 13:53:33 +0100 Subject: [PATCH 12/19] feat: implement product grid data fetching with schema self-healing and add a database verification script. --- next.config.ts | 2 +- scripts/diagnose-db.js | 48 +++++++++++++++++++++++ scripts/diagnose-db.ts | 27 +++++++++++++ src/features/grid/api/get-product-rows.ts | 20 ++++++++++ verify_limit.js | 37 +++++++++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 scripts/diagnose-db.js create mode 100644 scripts/diagnose-db.ts create mode 100644 verify_limit.js diff --git a/next.config.ts b/next.config.ts index 8328909..4422251 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,7 @@ const nextConfig = { serverExternalPackages: ["pg"], experimental: { serverActions: { - bodySizeLimit: "10mb", + bodySizeLimit: "50mb", }, }, typescript: { diff --git a/scripts/diagnose-db.js b/scripts/diagnose-db.js new file mode 100644 index 0000000..ad30ec5 --- /dev/null +++ b/scripts/diagnose-db.js @@ -0,0 +1,48 @@ + +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +async function main() { + console.log("--- DB DIAGNOSIS Start (Node) ---"); + + let connectionString = process.env.DATABASE_URL; + + try { + const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); + if (fs.existsSync(CONFIG_PATH)) { + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); + if (config.url) { + connectionString = config.url; + } + } + } catch (err) {} + + if (!connectionString) { + console.error("No connection string found."); + return; + } + + const pool = new Pool({ connectionString }); + + try { + const res = await pool.query(` + SELECT + code_fournisseur, + nom_fournisseur, + COUNT(*) as total_rows, + COUNT(DISTINCT codein) as distinct_products + FROM ventes_produits + GROUP BY code_fournisseur, nom_fournisseur + ORDER BY total_rows DESC + `); + + console.table(res.rows); + } catch (error) { + console.error("Diagnosis failed:", error); + } finally { + await pool.end(); + } +} + +main(); diff --git a/scripts/diagnose-db.ts b/scripts/diagnose-db.ts new file mode 100644 index 0000000..197e27b --- /dev/null +++ b/scripts/diagnose-db.ts @@ -0,0 +1,27 @@ + +import { db } from "@/db"; +import { ventesProduits } from "@/db/schema"; +import { sql } from "drizzle-orm"; + +async function main() { + console.log("--- DB DIAGNOSIS Start ---"); + try { + const results = await db.execute(sql` + SELECT + code_fournisseur, + nom_fournisseur, + COUNT(*) as total_rows, + COUNT(DISTINCT codein) as distinct_products + FROM ventes_produits + GROUP BY code_fournisseur, nom_fournisseur + ORDER BY total_rows DESC + `); + + console.table(results.rows); + } catch (error) { + console.error("Diagnosis failed:", error); + } + console.log("--- DB DIAGNOSIS End ---"); +} + +main(); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 54c3468..4166028 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -5,6 +5,8 @@ import { ventesProduits } from "@/db/schema"; import { eq, and, ne, like, sql } from "drizzle-orm"; import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; import { computeProductScores } from "@/lib/score-engine"; +import fs from "fs"; +import path from "path"; let isSchemaInitialized = false; @@ -70,6 +72,21 @@ export async function getProductRows(input: GetProductRowsInput): Promise(); + + // DEBUG: Log counts + console.log(`\n--- [getProductRows] DIAGNOSTIC for Supplier ${codeFournisseur} ---`); + const rawCount = await db.execute(sql` + SELECT COUNT(DISTINCT codein) as count FROM ventes_produits + WHERE code_fournisseur = ${codeFournisseur} + `); + console.log(`- TOTAL DISTINCT codein for this supplier in DB (unfiltered): ${rawCount.rows[0].count}`); + + console.log(`- DB Rows retrieved for current filters: ${rows.length}`); + + // Count distinct products in the DB results + const distinctInDb = new Set(rows.map(r => r.codein)).size; + console.log(`- Distinct Products in DB results: ${distinctInDb}`); + const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods for (const row of rows) { @@ -164,6 +181,9 @@ export async function getProductRows(input: GetProductRowsInput): Promise Date: Tue, 10 Mar 2026 15:30:43 +0100 Subject: [PATCH 13/19] feat: Implement `getProductRows` API to fetch, aggregate, and score product sales data, including a self-healing schema initialization for `code_gamme_init`. --- src/features/grid/api/get-product-rows.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 4166028..025f539 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -73,19 +73,20 @@ export async function getProductRows(input: GetProductRowsInput): Promise(); - // DEBUG: Log counts - console.log(`\n--- [getProductRows] DIAGNOSTIC for Supplier ${codeFournisseur} ---`); - const rawCount = await db.execute(sql` - SELECT COUNT(DISTINCT codein) as count FROM ventes_produits - WHERE code_fournisseur = ${codeFournisseur} - `); - console.log(`- TOTAL DISTINCT codein for this supplier in DB (unfiltered): ${rawCount.rows[0].count}`); + // EXTRA VERBOSE DIAGNOSTIC + console.log(`\n--- [getProductRows] ULTRA-DIAGNOSTIC for Supplier ${codeFournisseur} ---`); - console.log(`- DB Rows retrieved for current filters: ${rows.length}`); + const countAll = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); + const countDistinctTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); + const countNoTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur} AND magasin != 'TOTAL' AND periode != 'TOTAL'`); - // Count distinct products in the DB results - const distinctInDb = new Set(rows.map(r => r.codein)).size; - console.log(`- Distinct Products in DB results: ${distinctInDb}`); + console.log(`- Gross total rows in DB: ${countAll.rows[0].count}`); + console.log(`- Total distinct products (any store/period): ${countDistinctTotal.rows[0].count}`); + console.log(`- Distinct products excluding TOTAL store/period: ${countNoTotal.rows[0].count}`); + console.log(`- Current Filters rows retrieved: ${rows.length}`); + + const distinctInFetched = new Set(rows.map(r => r.codein)).size; + console.log(`- Distinct products in currently fetched rows: ${distinctInFetched}`); const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods From 10cea8c528530baaafe088f38a2e141c7019ba6c Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 15:52:29 +0100 Subject: [PATCH 14/19] feat: Implement product grid with a new API for fetching and processing product sales data, including schema initialization and score computation, and a dashboard page for display. --- src/app/(dashboard)/grid/page.tsx | 2 ++ src/features/grid/api/get-product-rows.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 5e2cc64..c6f8c5b 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -35,7 +35,9 @@ export default async function GridPage({ searchParams }: GridPageProps) { } // 3. Load real product data for the selected supplier + console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`); const rows = await getGridData(codeFournisseur, magasin, filters); + console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`); const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur); return ( diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 025f539..b02fad0 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -18,7 +18,9 @@ interface GetProductRowsInput { export async function getProductRows(input: GetProductRowsInput): Promise { const { codeFournisseur, magasin = "TOTAL", filters } = input; - + console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`); + console.log(`>>> [getProductRows] filters:`, JSON.stringify(filters)); + try { // Self-healing: Ensure codeGammeInit column exists and is populated if (!isSchemaInitialized) { From 1e5bb74344a3d3b91fc1e373c2621bfa5f4c19eb Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 15:59:44 +0100 Subject: [PATCH 15/19] feat: implement `getProductRows` API to fetch, filter, and aggregate product sales data for the grid, including schema initialization and scoring. --- src/features/grid/api/get-product-rows.ts | 30 ++++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index b02fad0..3e59544 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -18,6 +18,14 @@ interface GetProductRowsInput { export async function getProductRows(input: GetProductRowsInput): Promise { const { codeFournisseur, magasin = "TOTAL", filters } = input; + + // FILE LOGGING (portainer might be filtering console.log) + try { + const logPath = "/app/data/debug_check.txt"; + const entry = `\n[${new Date().toISOString()}] getProductRows called for ${codeFournisseur}\n`; + fs.appendFileSync(logPath, entry); + } catch (e) {} + console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`); console.log(`>>> [getProductRows] filters:`, JSON.stringify(filters)); @@ -76,19 +84,23 @@ export async function getProductRows(input: GetProductRowsInput): Promise(); // EXTRA VERBOSE DIAGNOSTIC - console.log(`\n--- [getProductRows] ULTRA-DIAGNOSTIC for Supplier ${codeFournisseur} ---`); - const countAll = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); const countDistinctTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); const countNoTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur} AND magasin != 'TOTAL' AND periode != 'TOTAL'`); - console.log(`- Gross total rows in DB: ${countAll.rows[0].count}`); - console.log(`- Total distinct products (any store/period): ${countDistinctTotal.rows[0].count}`); - console.log(`- Distinct products excluding TOTAL store/period: ${countNoTotal.rows[0].count}`); - console.log(`- Current Filters rows retrieved: ${rows.length}`); - - const distinctInFetched = new Set(rows.map(r => r.codein)).size; - console.log(`- Distinct products in currently fetched rows: ${distinctInFetched}`); + const diagOutput = ` +--- [getProductRows] ULTRA-DIAGNOSTIC for Supplier ${codeFournisseur} --- +- Gross total rows in DB: ${countAll.rows[0].count} +- Total distinct products (any store/period): ${countDistinctTotal.rows[0].count} +- Distinct products excluding TOTAL store/period: ${countNoTotal.rows[0].count} +- Current Filters rows retrieved: ${rows.length} +- Distinct products in currently fetched rows: ${new Set(rows.map(r => r.codein)).size} +`; + try { + fs.appendFileSync("/app/data/debug_check.txt", diagOutput); + } catch (e) {} + + console.log(diagOutput); const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods From 5eb3f6732e6b4f18e5afd02f6549d873ffca7c7d Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 16:04:55 +0100 Subject: [PATCH 16/19] feat: Add Drizzle ORM database setup, product data fetching and processing with scoring, and a diagnostic API endpoint. --- src/app/api/diag/route.ts | 69 ++++++++++++++++++ src/db/index.ts | 10 +++ src/features/grid/api/get-product-rows.ts | 86 +---------------------- 3 files changed, 81 insertions(+), 84 deletions(-) create mode 100644 src/app/api/diag/route.ts diff --git a/src/app/api/diag/route.ts b/src/app/api/diag/route.ts new file mode 100644 index 0000000..f6b34a0 --- /dev/null +++ b/src/app/api/diag/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { ventesProduits } from "@/db/schema"; +import { sql, eq } from "drizzle-orm"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const codeFournisseur = searchParams.get("fournisseur"); + + if (!codeFournisseur) { + return NextResponse.json({ + error: "Paramètre 'fournisseur' manquant.", + usage: "/api/diag?fournisseur=VOTRE_CODE" + }, { status: 400 }); + } + + try { + console.log(`\n--- [DIAG API] Starting check for supplier: ${codeFournisseur} ---`); + + // 1. Raw Count + const countAll = await db.execute(sql` + SELECT COUNT(*) as count FROM ventes_produits + WHERE code_fournisseur = ${codeFournisseur} + `); + const totalRows = Number(countAll.rows[0].count); + + // 2. Distinct Products + const countDistinctTotal = await db.execute(sql` + SELECT COUNT(DISTINCT codein) as count FROM ventes_produits + WHERE code_fournisseur = ${codeFournisseur} + `); + const totalDistinct = Number(countDistinctTotal.rows[0].count); + + // 3. Sample of data (to see if it's truncated by DB query) + const sample = await db.execute(sql` + SELECT codein, libelle1, magasin, periode FROM ventes_produits + WHERE code_fournisseur = ${codeFournisseur} + LIMIT 5 + `); + + console.log(`- DB sees ${totalRows} total rows.`); + console.log(`- DB sees ${totalDistinct} distinct products.`); + + return NextResponse.json({ + timestamp: new Date().toISOString(), + supplier: codeFournisseur, + database: { + totalRowsInTable: totalRows, + distinctProducts: totalDistinct, + sampleData: sample.rows + }, + serverConfig: { + nextVersion: "16.1.6", + nodeEnv: process.env.NODE_ENV, + bodySizeLimit: "50mb" + }, + message: totalDistinct > 1000 + ? "La base de données contient plus de 1000 produits. Si la grille en affiche 999, le problème est dans le transport (Application/RSC)." + : "La base de données renvoie moins de 1000 produits uniques. Le problème vient de l'import ou du filtrage SQL." + }); + + } catch (error: any) { + console.error("[DIAG API] Error:", error); + return NextResponse.json({ + error: "Failed to fetch diagnostic data", + details: error.message + }, { status: 500 }); + } +} diff --git a/src/db/index.ts b/src/db/index.ts index b7d9ff4..4ebe51b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -46,8 +46,18 @@ export function getDb() { console.error("[DB] CRITICAL: No database connection string available!"); } + const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown"; + console.log(`[DB] Target Hostname: ${connectionHostname}`); + pool = new Pool({ connectionString, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }); + + pool.on('error', (err) => { + console.error('[DB] Unexpected error on idle client', err); }); currentDb = drizzle(pool, { schema }); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 3e59544..e1556de 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -17,90 +17,8 @@ interface GetProductRowsInput { } export async function getProductRows(input: GetProductRowsInput): Promise { - const { codeFournisseur, magasin = "TOTAL", filters } = input; - - // FILE LOGGING (portainer might be filtering console.log) - try { - const logPath = "/app/data/debug_check.txt"; - const entry = `\n[${new Date().toISOString()}] getProductRows called for ${codeFournisseur}\n`; - fs.appendFileSync(logPath, entry); - } catch (e) {} - - console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`); - console.log(`>>> [getProductRows] filters:`, JSON.stringify(filters)); - - try { - // Self-healing: Ensure codeGammeInit column exists and is populated - if (!isSchemaInitialized) { - try { - await db.execute(sql` - ALTER TABLE ventes_produits - ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20) - `); - await db.execute(sql` - UPDATE ventes_produits - SET code_gamme_init = code_gamme - WHERE code_gamme_init IS NULL - `); - isSchemaInitialized = true; - console.log("[Schema] codeGammeInit initialized successfully."); - } catch (err) { - console.error("[Schema] Failed to initialize codeGammeInit:", err); - // We continue anyway, the mapping fallback will handle it for the UI - } - } - - // ... (allowedPeriods calculation remains the same) - const now = new Date(); - const allowedPeriods = new Set(); - for (let i = 12; i >= 0; i--) { // i >= 0 au lieu de 1 pour inclure le mois en cours - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - - // Build conditions - const conditions = [ - eq(ventesProduits.codeFournisseur, codeFournisseur), - ne(ventesProduits.periode, "TOTAL"), - magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"), - ]; - - // Hierarchical filtering - if (filters?.code3) { - conditions.push(eq(ventesProduits.code3, filters.code3)); - } else if (filters?.code2) { - conditions.push(like(ventesProduits.code3, `${filters.code2}%`)); - } else if (filters?.code1) { - conditions.push(like(ventesProduits.code3, `${filters.code1}%`)); - } - - // Get all rows for this supplier - const rows = await db - .select() - .from(ventesProduits) - .where(and(...conditions)); - - // Group by codein and aggregate monthly sales + track store participation - const productMap = new Map(); - - // EXTRA VERBOSE DIAGNOSTIC - const countAll = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); - const countDistinctTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur}`); - const countNoTotal = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = ${codeFournisseur} AND magasin != 'TOTAL' AND periode != 'TOTAL'`); - - const diagOutput = ` ---- [getProductRows] ULTRA-DIAGNOSTIC for Supplier ${codeFournisseur} --- -- Gross total rows in DB: ${countAll.rows[0].count} -- Total distinct products (any store/period): ${countDistinctTotal.rows[0].count} -- Distinct products excluding TOTAL store/period: ${countNoTotal.rows[0].count} -- Current Filters rows retrieved: ${rows.length} -- Distinct products in currently fetched rows: ${new Set(rows.map(r => r.codein)).size} -`; - try { - fs.appendFileSync("/app/data/debug_check.txt", diagOutput); - } catch (e) {} - - console.log(diagOutput); + // MODEL DIAGNOSTIC (Clean version) + console.log(`[getProductRows] Fetching for ${codeFournisseur} (${magasin}). Found ${rows.length} rows.`); const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods From 8237cf2d9fe68d92c5bbe8a1424f9973f29195b8 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 16:07:24 +0100 Subject: [PATCH 17/19] fix: snapshot deletion, restore grid logic and add diagnostic API --- src/features/grid/api/get-product-rows.ts | 65 +++++++++++++++---- src/features/snapshots/api/delete-snapshot.ts | 27 ++++++-- src/features/snapshots/api/get-snapshots.ts | 21 +++--- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index e1556de..23bcc68 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -17,16 +17,64 @@ interface GetProductRowsInput { } export async function getProductRows(input: GetProductRowsInput): Promise { - // MODEL DIAGNOSTIC (Clean version) + const { codeFournisseur, magasin = "TOTAL", filters } = input; + console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`); + + try { + // Self-healing: Ensure codeGammeInit column exists and is populated + if (!isSchemaInitialized) { + try { + await db.execute(sql` + ALTER TABLE ventes_produits + ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20) + `); + await db.execute(sql` + UPDATE ventes_produits + SET code_gamme_init = code_gamme + WHERE code_gamme_init IS NULL + `); + isSchemaInitialized = true; + console.log("[Schema] codeGammeInit initialized successfully."); + } catch (err) { + console.error("[Schema] Failed to initialize codeGammeInit:", err); + } + } + + const now = new Date(); + const allowedPeriods = new Set(); + for (let i = 12; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); + } + + const conditions = [ + eq(ventesProduits.codeFournisseur, codeFournisseur), + ne(ventesProduits.periode, "TOTAL"), + magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"), + ]; + + if (filters?.code3) { + conditions.push(eq(ventesProduits.code3, filters.code3)); + } else if (filters?.code2) { + conditions.push(like(ventesProduits.code3, `${filters.code2}%`)); + } else if (filters?.code1) { + conditions.push(like(ventesProduits.code3, `${filters.code1}%`)); + } + + const rows = await db + .select() + .from(ventesProduits) + .where(and(...conditions)); + console.log(`[getProductRows] Fetching for ${codeFournisseur} (${magasin}). Found ${rows.length} rows.`); + const productMap = new Map(); const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods for (const row of rows) { const { codein, magasin, periode } = row; const isInPeriod = allowedPeriods.has(periode); - // Suivi de la participation par magasin (seulement si dans la période et pas TOTAL) if (isInPeriod && magasin !== "TOTAL") { if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map()); const productStores = storeParticipationMap.get(codein)!; @@ -36,20 +84,13 @@ export async function getProductRows(input: GetProductRowsInput): Promise = { - "20": "LIQUIDES", - "30": "ÉPICERIE", - "40": "DPH", - "50": "BAZAR", - "60": "TEXTILE", - "70": "FRAIS", - // Rayons + "20": "LIQUIDES", "30": "ÉPICERIE", "40": "DPH", "50": "BAZAR", "60": "TEXTILE", "70": "FRAIS", "2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS", "3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES", "4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE", @@ -85,7 +126,6 @@ export async function getProductRows(input: GetProductRowsInput): Promise= 3 mois de vente) for (const [codein, product] of productMap.entries()) { const productStores = storeParticipationMap.get(codein); if (productStores) { @@ -114,9 +153,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0) { - return query.where(and(...conditions)).orderBy(desc(sessionSnapshots.createdAt)); + if (!userId || isNaN(userId)) { + console.warn("[getSnapshots] Unauthenticated access attempt"); + return []; } - return query.orderBy(desc(sessionSnapshots.createdAt)); + const conditions = [eq(sessionSnapshots.userId, userId)]; + if (type) conditions.push(eq(sessionSnapshots.type, type)); + + return await db.select() + .from(sessionSnapshots) + .where(and(...conditions)) + .orderBy(desc(sessionSnapshots.createdAt)); } catch (err: any) { const msg = (err?.message || String(err)).split("\n")[0]; console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err); From 85972b287d91d869946929d298f705e2b6581a09 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 16:16:23 +0100 Subject: [PATCH 18/19] fix: restore anonymous snapshot visibility and deletion --- src/features/snapshots/api/delete-snapshot.ts | 21 +++++++++---------- src/features/snapshots/api/get-snapshots.ts | 13 ++++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts index a42cc55..0cbb69c 100644 --- a/src/features/snapshots/api/delete-snapshot.ts +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -2,7 +2,7 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { auth } from "@/lib/auth"; export async function deleteSnapshot(id: number) { @@ -10,22 +10,21 @@ export async function deleteSnapshot(id: number) { const rawUserId = (session?.user as any)?.id; const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; - console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId} (raw: ${rawUserId})`); - - if (!userId || isNaN(userId)) { - console.error("[deleteSnapshot] Unauthorized: userId is missing or invalid"); - return { success: false, error: "Non autorisé" }; - } + console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`); try { + // Condition de sécurité : match ID ET match le propriétaire (ou anonyme) + const userCondition = userId && !isNaN(userId) + ? eq(sessionSnapshots.userId, userId) + : isNull(sessionSnapshots.userId); + const result = await db.delete(sessionSnapshots) - .where(and(eq(sessionSnapshots.id, id), eq(sessionSnapshots.userId, userId))) + .where(and(eq(sessionSnapshots.id, id), userCondition)) .returning({ deletedId: sessionSnapshots.id }); if (result.length === 0) { - console.warn(`[deleteSnapshot] No rows deleted for ID ${id} and User ${userId}. Maybe the snapshot doesn't belong to the user?`); - // We return success: false to trigger the error modal in UI if nothing was actually deleted - return { success: false, error: "Élément non trouvé ou non autorisé" }; + console.warn(`[deleteSnapshot] No rows deleted for ID ${id}. Snapshot may not exist or belongs to another user.`); + return { success: false, error: "Snapshot non trouvé ou non autorisé" }; } console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`); diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index 2d488a5..488aef6 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -3,7 +3,7 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { auth } from "@/lib/auth"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; export async function getSnapshots(type?: "snapshot" | "export") { try { @@ -11,12 +11,13 @@ export async function getSnapshots(type?: "snapshot" | "export") { const rawUserId = (session?.user as any)?.id; const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; - if (!userId || isNaN(userId)) { - console.warn("[getSnapshots] Unauthenticated access attempt"); - return []; - } + // Si on a un userId, on filtre par celui-ci. + // Sinon, on filtre par userId IS NULL (mode anonyme/local) + const userCondition = userId && !isNaN(userId) + ? eq(sessionSnapshots.userId, userId) + : isNull(sessionSnapshots.userId); - const conditions = [eq(sessionSnapshots.userId, userId)]; + const conditions = [userCondition]; if (type) conditions.push(eq(sessionSnapshots.type, type)); return await db.select() From 05549fbde5a41ef3ea54988fe397352cfdf6fd57 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 10 Mar 2026 16:29:45 +0100 Subject: [PATCH 19/19] fix: add cache revalidation to snapshot APIs and fix imports --- src/features/snapshots/api/save-snapshot.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index 4184b26..e539762 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -5,6 +5,7 @@ import { sessionSnapshots } from "@/db/schema"; import { z } from "zod"; import { sql, and, eq } from "drizzle-orm"; import { auth } from "@/lib/auth"; +import { revalidatePath } from "next/cache"; const SaveSnapshotSchema = z.object({ codeFournisseur: z.string(), @@ -33,13 +34,18 @@ export async function saveSnapshot(raw: unknown) { const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data; const session = await auth(); - const userId = session?.user ? Number((session.user as any).id) : null; + const rawUserId = (session?.user as any)?.id; + const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; + + console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`); + + const finalUserId = (userId && !isNaN(userId)) ? userId : null; try { const [created] = await db .insert(sessionSnapshots) .values({ - userId, + userId: finalUserId, codeFournisseur, nomFournisseur: nomFournisseur ?? null, magasin, @@ -49,7 +55,9 @@ export async function saveSnapshot(raw: unknown) { type: type ?? "snapshot", }) .returning({ id: sessionSnapshots.id }); - + + revalidatePath("/snapshots"); + revalidatePath("/exports"); return { success: true, snapshotId: created?.id }; } catch (err: any) { console.error("Initial snapshot save failed, attempting auto-repair...", err); @@ -83,7 +91,7 @@ export async function saveSnapshot(raw: unknown) { const [retryCreated] = await db .insert(sessionSnapshots) .values({ - userId, + userId: finalUserId, codeFournisseur, nomFournisseur: nomFournisseur ?? null, magasin,