From e79d7e51daa967fcd3cac2f3fd99361919ffd6df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:35:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20API=20CollectFlow=20/api/v1=20?= =?UTF-8?q?=E2=80=94=20lecture=20de=20la=20grille=20et=20recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et getProductRows() exige un fournisseur : chercher un produit sans le connaître était impossible. Contrainte de conception : l'API ne recalcule jamais rien. La grille était reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui appellerait getProductRows() serait lente et imprévisible. On persiste donc le résultat d'un calcul qui a déjà lieu, et on le sert. - Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) + payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant par getProductRows(), purge des articles disparus via computed_at. Survit aux redémarrages, contrairement au cache mémoire. - Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous fournisseurs), products/:codein, network/:codeCentrale, openapi.json. Pagination, tri sur liste blanche, projection de champs, validation zod. 202 not_ready si un fournisseur n'a pas encore d'instantané. - Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base, révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi un script recevait une redirection 302 vers /login au lieu d'un 401 JSON. - Gestion des clés dans /settings (server actions, clé affichée une seule fois). Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et 24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route /api/grid/rows en déclenche un à chaque appel). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- scripts/db-init.js | 56 ++++ src/app/(dashboard)/settings/page.tsx | 9 + src/app/api/v1/fournisseurs/route.ts | 62 ++++ src/app/api/v1/grid/route.ts | 65 +++++ .../api/v1/network/[codeCentrale]/route.ts | 40 +++ src/app/api/v1/openapi.json/route.ts | 181 ++++++++++++ src/app/api/v1/products/[codein]/route.ts | 55 ++++ src/app/api/v1/products/search/route.ts | 52 ++++ src/db/schema.ts | 76 +++++ src/features/admin/api/api-key-actions.ts | 117 ++++++++ .../admin/components/api-key-management.tsx | 217 ++++++++++++++ src/features/grid/api/get-product-rows.ts | 22 ++ src/lib/api-auth.ts | 122 ++++++++ src/lib/api-response.ts | 67 +++++ src/lib/api-schemas.ts | 87 ++++++ src/lib/grid-store.ts | 274 ++++++++++++++++++ src/middleware.ts | 7 + 17 files changed, 1509 insertions(+) create mode 100644 src/app/api/v1/fournisseurs/route.ts create mode 100644 src/app/api/v1/grid/route.ts create mode 100644 src/app/api/v1/network/[codeCentrale]/route.ts create mode 100644 src/app/api/v1/openapi.json/route.ts create mode 100644 src/app/api/v1/products/[codein]/route.ts create mode 100644 src/app/api/v1/products/search/route.ts create mode 100644 src/features/admin/api/api-key-actions.ts create mode 100644 src/features/admin/components/api-key-management.tsx create mode 100644 src/lib/api-auth.ts create mode 100644 src/lib/api-response.ts create mode 100644 src/lib/api-schemas.ts create mode 100644 src/lib/grid-store.ts diff --git a/scripts/db-init.js b/scripts/db-init.js index 9c2cecc..0249758 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -119,6 +119,62 @@ async function main() { `); console.log("[DB Init] Table qlik_network_metrics is verified/created."); + // Instantané persisté de la grille : lu par /api/v1 pour ne jamais + // déclencher de recalcul. Rempli en effet de bord par getProductRows(). + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "grid_rows" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "libelle1" varchar(500), + "gtin" varchar(30), + "reference" varchar(100), + "code_centrale" varchar(20), + "code1" varchar(20), + "code2" varchar(20), + "code3" varchar(20), + "code_gamme" varchar(20), + "code_gamme_init" varchar(20), + "total_ca" numeric(16, 2), + "total_quantite" numeric(14, 2), + "total_marge" numeric(16, 2), + "taux_marge" numeric(8, 4), + "stock_actuel" numeric(14, 2), + "ca_reseau" numeric(16, 2), + "qte_reseau" numeric(14, 2), + "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "marge_pct_reseau" numeric(8, 4), + "payload" jsonb NOT NULL, + "computed_at" timestamp DEFAULT now() NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein" + ON "grid_rows" ("code_fournisseur", "codein"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca"); + CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1"); + `); + console.log("[DB Init] Table grid_rows is verified/created."); + + // Clés d'API : seul le hachage SHA-256 est stocké. + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "api_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "key_prefix" varchar(20) NOT NULL, + "key_hash" text NOT NULL UNIQUE, + "role" varchar(20) DEFAULT 'user' NOT NULL, + "created_by" varchar(50), + "created_at" timestamp DEFAULT now(), + "last_used_at" timestamp, + "revoked_at" timestamp + ); + `); + console.log("[DB Init] Table api_keys is verified/created."); + await tempPool.end(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index e288523..3116e8a 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -8,6 +8,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions"; import { useEffect } from "react"; import { UserManagement } from "@/features/admin/components/user-management"; +import { ApiKeyManagement } from "@/features/admin/components/api-key-management"; interface OpenRouterModel { id: string; name: string; free: boolean; } @@ -616,6 +617,14 @@ export default function SettingsPage() { + {/* Clés de l'API publique /api/v1 */} +
+ +
+ {/* Save Button — Floating/Sticky style at bottom */}
+
+ + + )} + + {/* Création */} +
+ setName(e.target.value)} + placeholder="Nom de la clé (ex. Export comptabilité)" + className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} + /> + + +
+ + {error && ( +

{error}

+ )} + + {/* Liste */} + {loading ? ( +
+ Chargement… +
+ ) : keys.length === 0 ? ( +

Aucune clé pour le moment.

+ ) : ( +
+ + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + ))} + +
NomPréfixeRôleCrééeDernier usage
+ + + {k.name} + + {k.revokedAt && ( + + Révoquée + + )} + {k.keyPrefix}…{k.role}{fmtDate(k.createdAt)}{fmtDate(k.lastUsedAt)} + {!k.revokedAt ? ( + + ) : ( + + )} +
+
+ )} + + ); +} diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 1a8b6ca..cf7dc78 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -15,6 +15,7 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; +import { upsertGridRows } from "@/lib/grid-store"; const NB_MAGASINS_RESEAU = 270; @@ -67,6 +68,23 @@ async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Pr } } +/** + * Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`). + * + * Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations + * par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes. + * Toute erreur est avalée : la persistance ne doit jamais casser l'affichage. + */ +async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise { + if ((input.magasin ?? "TOTAL") !== "TOTAL") return; + try { + const n = await upsertGridRows(input.codeFournisseur, rows); + console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`); + } catch (e) { + console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200)); + } +} + export async function getProductRows(input: GetProductRowsInput): Promise { const cacheKey = gridRowsCacheKey(input); const cached = gridRowsCache.get(cacheKey); @@ -85,6 +103,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise { gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() }); gridRowsPending.delete(cacheKey); + // Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on + // arrête simplement de jeter le résultat. Volontairement NON bloquant — + // l'affichage de la Grille ne doit pas attendre l'écriture. + void persistGridSnapshot(input, rows); return rows; }).catch((error) => { gridRowsPending.delete(cacheKey); diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 0000000..d9f3907 --- /dev/null +++ b/src/lib/api-auth.ts @@ -0,0 +1,122 @@ +/** + * CollectFlow — Authentification de l'API publique `/api/v1`. + * + * Deux voies d'entrée : + * 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants + * non-navigateur : scripts, outils externes, assistant IA. + * 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes. + * + * En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts` + * exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement. + * + * Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair + * n'existe qu'au moment de sa création. + */ + +import "server-only"; + +import { createHash, randomBytes, timingSafeEqual } from "crypto"; +import type { NextRequest } from "next/server"; +import { db } from "@/db"; +import { apiKeys } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { fail } from "@/lib/api-response"; + +export interface AuthContext { + /** Origine de l'authentification — utile pour les logs et l'audit. */ + via: "api_key" | "session"; + role: string; + /** Nom de la clé, ou identifiant de l'utilisateur connecté. */ + subject: string; +} + +/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */ +const KEY_PREFIX = "cf_"; + +export function hashApiKey(key: string): string { + return createHash("sha256").update(key, "utf8").digest("hex"); +} + +/** + * Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois), + * son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI. + */ +export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } { + const secret = randomBytes(24).toString("base64url"); + const key = `${KEY_PREFIX}${secret}`; + return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) }; +} + +/** Comparaison à temps constant de deux hachages hexadécimaux. */ +function hashesEqual(a: string, b: string): boolean { + const ba = Buffer.from(a, "hex"); + const bb = Buffer.from(b, "hex"); + if (ba.length !== bb.length || ba.length === 0) return false; + return timingSafeEqual(ba, bb); +} + +/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */ +function readKeyFromRequest(req: NextRequest): string | null { + const direct = req.headers.get("x-api-key"); + if (direct?.trim()) return direct.trim(); + const authz = req.headers.get("authorization"); + if (authz) { + const m = authz.match(/^Bearer\s+(.+)$/i); + if (m) return m[1].trim(); + } + return null; +} + +/** + * Authentifie une requête `/api/v1`. + * + * Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à + * être retournée par le handler. À appeler en première ligne de chaque endpoint : + * + * const authCtx = await requireApiAuth(req); + * if (authCtx instanceof NextResponse) return authCtx; + */ +export async function requireApiAuth(req: NextRequest): Promise { + const presented = readKeyFromRequest(req); + + if (presented) { + const presentedHash = hashApiKey(presented); + // La colonne key_hash est unique : la recherche par égalité suffit, et la + // comparaison à temps constant ci-dessous couvre le reste. + const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1); + + if (!row || !hashesEqual(row.keyHash, presentedHash)) { + return fail("unauthorized", "Clé d'API invalide."); + } + if (row.revokedAt) { + return fail("unauthorized", "Clé d'API révoquée."); + } + + // Trace d'usage — jamais bloquante pour la requête en cours. + void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id)) + .catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120))); + + return { via: "api_key", role: row.role, subject: row.name }; + } + + // Repli sur la session du navigateur (le front utilise les mêmes routes). + const session = await auth(); + if (session?.user) { + const user = session.user as { name?: string | null; role?: string }; + return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" }; + } + + return fail( + "unauthorized", + "Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.", + ); +} + +/** Restreint un endpoint aux appelants administrateurs. */ +export function requireAdminRole(ctx: AuthContext): Response | null { + if (ctx.role !== "admin") { + return fail("forbidden", "Rôle administrateur requis pour cette opération."); + } + return null; +} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..1677ce3 --- /dev/null +++ b/src/lib/api-response.ts @@ -0,0 +1,67 @@ +/** + * CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`. + * + * Une forme unique pour tout : `{ data, pagination?, meta }` en succès, + * `{ error: { code, message } }` en échec, avec de vrais statuts HTTP. + * Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter + * toutes les réponses de la même manière. + */ + +import { NextResponse } from "next/server"; + +export type ApiErrorCode = + | "unauthorized" + | "forbidden" + | "bad_request" + | "not_found" + | "not_ready" + | "internal_error"; + +const STATUS_BY_CODE: Record = { + unauthorized: 401, + forbidden: 403, + bad_request: 400, + not_found: 404, + not_ready: 202, + internal_error: 500, +}; + +export interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export function buildPagination(page: number, limit: number, total: number): Pagination { + return { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)) }; +} + +/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */ +export function ok( + data: T, + opts: { pagination?: Pagination; meta?: Record } = {}, +): NextResponse { + return NextResponse.json({ + data, + ...(opts.pagination ? { pagination: opts.pagination } : {}), + meta: { ...(opts.meta ?? {}) }, + }); +} + +/** + * Réponse d'erreur. `details` sert notamment aux erreurs de validation zod. + * + * Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas + * encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert). + */ +export function fail( + code: ApiErrorCode, + message: string, + details?: unknown, +): NextResponse { + return NextResponse.json( + { error: { code, message, ...(details !== undefined ? { details } : {}) } }, + { status: STATUS_BY_CODE[code] }, + ); +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts new file mode 100644 index 0000000..b18dc8d --- /dev/null +++ b/src/lib/api-schemas.ts @@ -0,0 +1,87 @@ +/** + * CollectFlow — Validation des paramètres de l'API `/api/v1` (zod). + * + * Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches + * de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec + * le détail zod, pour que l'appelant sache quoi corriger. + */ + +import { z } from "zod"; +import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store"; +import type { ProductRow } from "@/types/grid"; + +const SORT_KEYS = GRID_SORT_KEYS as readonly string[]; + +export const paginationShape = { + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(500).default(100), +}; + +const sortShape = { + sort: z + .string() + .refine((v) => SORT_KEYS.includes(v), { + message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`, + }) + .optional(), + order: z.enum(["asc", "desc"]).default("desc"), + /** Liste de champs de ProductRow à conserver, séparés par des virgules. */ + fields: z.string().optional(), +}; + +/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */ +export const gridQuerySchema = z.object({ + fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"), + gamme: z.string().min(1).optional(), + code1: z.string().min(1).optional(), + code2: z.string().min(1).optional(), + code3: z.string().min(1).optional(), + search: z.string().min(1).optional(), + ...sortShape, + ...paginationShape, +}); + +/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */ +export const productSearchSchema = z.object({ + q: z.string().min(1, "Paramètre 'q' requis").max(120), + fournisseur: z.string().min(1).optional(), + gamme: z.string().min(1).optional(), + ...sortShape, + ...paginationShape, +}); + +export const fournisseursQuerySchema = z.object({ + search: z.string().min(1).optional(), + /** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */ + withData: z.enum(["0", "1"]).optional(), +}); + +export const productDetailSchema = z.object({ + fournisseur: z.string().min(1).optional(), +}); + +/** + * Projection de champs : `?fields=codein,libelle1,totalCa`. + * + * Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles + * et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de + * lignes. `codein` est toujours conservé pour que la ligne reste identifiable. + */ +export function pickFields(row: ProductRow, fields?: string): Partial { + if (!fields) return row; + const wanted = new Set( + fields.split(",").map((f) => f.trim()).filter(Boolean), + ); + if (wanted.size === 0) return row; + wanted.add("codein"); + const out: Record = {}; + for (const key of wanted) { + if (key in row) out[key] = (row as unknown as Record)[key]; + } + return out as Partial; +} + +/** Normalise le tri validé vers la clé attendue par grid-store. */ +export function toSortKey(sort?: string): GridSortKey | undefined { + return sort as GridSortKey | undefined; +} diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts new file mode 100644 index 0000000..fc33ddc --- /dev/null +++ b/src/lib/grid-store.ts @@ -0,0 +1,274 @@ +/** + * CollectFlow — Instantané persisté de la grille (table `grid_rows`). + * + * `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base + * miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1` + * ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de + * bord quand quelqu'un ouvre la Grille. + * + * On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat + * qui existe déjà, et il survit désormais aux redémarrages. + */ + +import "server-only"; + +import { db } from "@/db"; +import { gridRows } from "@/db/schema"; +import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm"; +import type { ProductRow } from "@/types/grid"; + +/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */ +const SORTABLE = { + libelle1: gridRows.libelle1, + codein: gridRows.codein, + codeFournisseur: gridRows.codeFournisseur, + codeGamme: gridRows.codeGamme, + totalCa: gridRows.totalCa, + totalQuantite: gridRows.totalQuantite, + totalMarge: gridRows.totalMarge, + tauxMarge: gridRows.tauxMarge, + stockActuel: gridRows.stockActuel, + caReseau: gridRows.caReseau, + qteReseau: gridRows.qteReseau, + nbMagasinsReseau: gridRows.nbMagasinsReseau, + caParMagasinReseau: gridRows.caParMagasinReseau, + computedAt: gridRows.computedAt, +} as const; + +export type GridSortKey = keyof typeof SORTABLE; +export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[]; + +export interface GridQuery { + codeFournisseur?: string; + gamme?: string; + code1?: string; + code2?: string; + code3?: string; + /** Recherche libre : libellé, codein, GTIN, référence, code centrale. */ + search?: string; + sort?: GridSortKey; + order?: "asc" | "desc"; + page?: number; + limit?: number; +} + +export interface GridQueryResult { + rows: ProductRow[]; + total: number; + /** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */ + computedAt: string | null; +} + +/** + * Persiste les lignes calculées pour un fournisseur. + * + * Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime + * celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles + * disparus du catalogue sans avoir à passer des centaines de codein en paramètres. + */ +export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise { + if (!codeFournisseur || rows.length === 0) return 0; + const computedAt = new Date(); + + const values = rows + .filter((r) => r.codein) + .map((r) => ({ + codeFournisseur, + codein: r.codein, + nomFournisseur: r.nomFournisseur ?? null, + libelle1: r.libelle1 ?? null, + gtin: r.gtin ?? null, + reference: r.reference ?? null, + codeCentrale: r.codeCentrale ?? null, + code1: r.code1 ?? null, + code2: r.code2 ?? null, + code3: r.code3 ?? null, + codeGamme: r.codeGamme ?? null, + codeGammeInit: r.codeGammeInit ?? null, + totalCa: r.totalCa != null ? String(r.totalCa) : null, + totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null, + totalMarge: r.totalMarge != null ? String(r.totalMarge) : null, + tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null, + stockActuel: r.stockActuel != null ? String(r.stockActuel) : null, + caReseau: r.caReseau != null ? String(r.caReseau) : null, + qteReseau: r.qteReseau != null ? String(r.qteReseau) : null, + nbMagasinsReseau: r.nbMagasinsReseau ?? null, + caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null, + margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null, + payload: r, + computedAt, + })); + if (values.length === 0) return 0; + + const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires + let written = 0; + for (let i = 0; i < values.length; i += CHUNK) { + const batch = values.slice(i, i + CHUNK); + await db + .insert(gridRows) + .values(batch) + .onConflictDoUpdate({ + target: [gridRows.codeFournisseur, gridRows.codein], + set: { + nomFournisseur: sql`excluded.nom_fournisseur`, + libelle1: sql`excluded.libelle1`, + gtin: sql`excluded.gtin`, + reference: sql`excluded.reference`, + codeCentrale: sql`excluded.code_centrale`, + code1: sql`excluded.code1`, + code2: sql`excluded.code2`, + code3: sql`excluded.code3`, + codeGamme: sql`excluded.code_gamme`, + codeGammeInit: sql`excluded.code_gamme_init`, + totalCa: sql`excluded.total_ca`, + totalQuantite: sql`excluded.total_quantite`, + totalMarge: sql`excluded.total_marge`, + tauxMarge: sql`excluded.taux_marge`, + stockActuel: sql`excluded.stock_actuel`, + caReseau: sql`excluded.ca_reseau`, + qteReseau: sql`excluded.qte_reseau`, + nbMagasinsReseau: sql`excluded.nb_magasins_reseau`, + caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`, + margePctReseau: sql`excluded.marge_pct_reseau`, + payload: sql`excluded.payload`, + computedAt: sql`excluded.computed_at`, + }, + }); + written += batch.length; + } + + // Purge des articles qui ne font plus partie du catalogue du fournisseur. + await db.delete(gridRows).where( + and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)), + ); + + return written; +} + +/** Construit la clause WHERE commune à la grille et à la recherche transversale. */ +function buildWhere(q: GridQuery): SQL | undefined { + const clauses: SQL[] = []; + if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur)); + if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme)); + if (q.code1) clauses.push(eq(gridRows.code1, q.code1)); + if (q.code2) clauses.push(eq(gridRows.code2, q.code2)); + if (q.code3) clauses.push(eq(gridRows.code3, q.code3)); + const search = q.search?.trim(); + if (search) { + const pattern = `%${search}%`; + const alt = or( + ilike(gridRows.libelle1, pattern), + ilike(gridRows.codein, pattern), + ilike(gridRows.gtin, pattern), + ilike(gridRows.reference, pattern), + ilike(gridRows.codeCentrale, pattern), + ); + if (alt) clauses.push(alt); + } + return clauses.length ? and(...clauses) : undefined; +} + +/** + * Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL. + * Ne déclenche aucun recalcul et ne contacte jamais Qlik. + */ +export async function queryGridRows(q: GridQuery): Promise { + const page = Math.max(1, q.page ?? 1); + const limit = Math.min(500, Math.max(1, q.limit ?? 100)); + const where = buildWhere(q); + + const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa; + // NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête. + const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol); + + const [countRow] = await db + .select({ total: sql`count(*)::int` }) + .from(gridRows) + .where(where); + + const found = await db + .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) + .from(gridRows) + .where(where) + .orderBy(orderBy, asc(gridRows.codein)) + .limit(limit) + .offset((page - 1) * limit); + + let oldest: Date | null = null; + for (const r of found) { + if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt; + } + + return { + rows: found.map((r) => r.payload as ProductRow), + total: countRow?.total ?? 0, + computedAt: oldest ? oldest.toISOString() : null, + }; +} + +/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */ +export async function getGridRowByCodein( + codein: string, + codeFournisseur?: string, +): Promise<{ row: ProductRow; computedAt: string | null } | null> { + const clauses: SQL[] = [eq(gridRows.codein, codein)]; + if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur)); + + const [found] = await db + .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) + .from(gridRows) + .where(and(...clauses)) + .orderBy(desc(gridRows.computedAt)) + .limit(1); + + if (!found) return null; + return { + row: found.payload as ProductRow, + computedAt: found.computedAt ? found.computedAt.toISOString() : null, + }; +} + +/** + * Indique si un fournisseur a déjà été calculé au moins une fois. + * Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long. + */ +export async function getGridFreshness( + codeFournisseur: string, +): Promise<{ rowCount: number; computedAt: string | null } | null> { + const [found] = await db + .select({ + rowCount: sql`count(*)::int`, + computedAt: sql`max(${gridRows.computedAt})`, + }) + .from(gridRows) + .where(eq(gridRows.codeFournisseur, codeFournisseur)); + + if (!found || found.rowCount === 0) return null; + return { + rowCount: found.rowCount, + computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null, + }; +} + +/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */ +export async function listGridSuppliers(): Promise< + Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }> +> { + const found = await db + .select({ + codeFournisseur: gridRows.codeFournisseur, + nomFournisseur: sql`max(${gridRows.nomFournisseur})`, + rowCount: sql`count(*)::int`, + computedAt: sql`max(${gridRows.computedAt})`, + }) + .from(gridRows) + .groupBy(gridRows.codeFournisseur) + .orderBy(asc(gridRows.codeFournisseur)); + + return found.map((r) => ({ + codeFournisseur: r.codeFournisseur, + nomFournisseur: r.nomFournisseur, + rowCount: r.rowCount, + computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null, + })); +} diff --git a/src/middleware.ts b/src/middleware.ts index 4a9fc52..f7c6ca7 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,10 +10,17 @@ export default auth((req) => { const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth"); const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/"); + // L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses + // propres erreurs JSON. Sans cette exemption, un script non authentifié + // recevrait une redirection 302 + la page HTML de login au lieu d'un 401. + const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1"); // 1. Laisser passer les requêtes d'auth API if (isApiAuthRoute) return NextResponse.next(); + // 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers + if (isPublicApiRoute) return NextResponse.next(); + // 2. Rediriger vers /login si non connecté et route non publique if (!isLoggedIn && !isPublicRoute) { return NextResponse.redirect(new URL("/login", nextUrl));