feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-05 10:14:21 +00:00
1 parent 03b6ddb8c9
commit e79d7e51da
17 files changed
+1509

No files matched your search

+56
View File
@@ -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);
+9
View File
@@ -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() {
<UserManagement />
</Section>
{/* Clés de l'API publique /api/v1 */}
<Section
title="Clés d'API"
subtitle="Accès programmatique en lecture à la grille et à la recherche (/api/v1)"
>
<ApiKeyManagement />
</Section>
{/* Save Button — Floating/Sticky style at bottom */}
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
<button
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { fournisseursQuerySchema } from "@/lib/api-schemas";
import { listGridSuppliers } from "@/lib/grid-store";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/fournisseurs?search=…&withData=1
*
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
*
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { search, withData } = parsed.data;
const snapshot = await listGridSuppliers();
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
if (withData === "1") {
const term = search?.toLowerCase();
const data = snapshot
.filter((s) => !term
|| s.codeFournisseur.toLowerCase().includes(term)
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
.map((s) => ({
code: s.codeFournisseur,
nom: s.nomFournisseur,
hasSnapshot: true,
rowCount: s.rowCount,
computedAt: s.computedAt,
}));
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
}
const fournisseurs = await pgGetFournisseurs(search);
const data = fournisseurs.map((f) => {
const snap = byCode.get(f.code);
return {
code: f.code,
nom: f.nom,
hasSnapshot: Boolean(snap),
rowCount: snap?.rowCount ?? 0,
computedAt: snap?.computedAt ?? null,
};
});
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=
*
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
* l'instantané persisté (`grid_rows`).
*
* Cet endpoint ne déclenche **jamais** `getProductRows()` et ne contacte jamais Qlik :
* si le fournisseur n'a pas encore d'instantané, il répond `202 not_ready` plutôt que
* d'imposer un calcul de plusieurs secondes à l'appelant.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
// Distingue « fournisseur jamais calculé » (202) de « filtres sans résultat » (200 vide).
const freshness = await getGridFreshness(q.fournisseur);
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Ouvrez-le une fois dans la Grille pour le calculer.`,
{ fournisseur: q.fournisseur },
);
}
const result = await queryGridRows({
codeFournisseur: q.fournisseur,
gamme: q.gamme,
code1: q.code1,
code2: q.code2,
code3: q.code3,
search: q.search,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
return ok(
result.rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
meta: {
fournisseur: q.fournisseur,
computedAt: result.computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
},
},
);
}
@@ -0,0 +1,40 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/network/:codeCentrale
*
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
*
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
* référençons pas), c'est `/api/qlik/search` — bien plus lent, et volontairement
* séparé de cette API.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codeCentrale: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codeCentrale } = await ctx.params;
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
const found = metrics.get(codeCentrale);
if (!found) {
return fail(
"not_found",
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
);
}
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
}
+181
View File
@@ -0,0 +1,181 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/openapi.json
*
* Spécification lisible par machine de l'API CollectFlow. Permet aux outils externes
* — et à l'assistant IA — de découvrir les endpoints sans documentation manuscrite.
* Authentifiée comme le reste de l'API : la spec décrit une surface interne.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const paginationParams = [
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
];
const sortParams = [
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" } },
{
name: "fields",
in: "query",
schema: { type: "string" },
description: "Champs de ProductRow à conserver, séparés par des virgules (allège fortement la réponse). `codein` est toujours inclus.",
},
];
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"Lecture de la grille CollectFlow (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) "
+ "et recherche de produits.\n\n"
+ "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Toutes les réponses proviennent "
+ "de données déjà persistées : l'instantané de grille (`grid_rows`), rempli quand la Grille est "
+ "ouverte dans l'application, et le cache des métriques réseau (`qlik_network_metrics`). "
+ "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.",
},
servers: [{ url: "/api/v1" }],
security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
BearerAuth: { type: "http", scheme: "bearer" },
},
schemas: {
Error: {
type: "object",
properties: {
error: {
type: "object",
properties: {
code: {
type: "string",
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
},
message: { type: "string" },
details: {},
},
},
},
},
Pagination: {
type: "object",
properties: {
page: { type: "integer" },
limit: { type: "integer" },
total: { type: "integer" },
totalPages: { type: "integer" },
},
},
ProductRow: {
type: "object",
description: "Ligne de grille. Voir src/types/grid.ts pour la liste complète des champs.",
properties: {
codein: { type: "string", description: "Identifiant interne FF Nancy." },
codeFournisseur: { type: "string" },
nomFournisseur: { type: "string" },
libelle1: { type: "string" },
gtin: { type: "string" },
codeCentrale: { type: "string", description: "Clé de jointure avec Qlik (format 10000XXXXXX)." },
codeGamme: { type: ["string", "null"] },
totalCa: { type: "number" },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
tauxMarge: { type: "number" },
stockActuel: { type: "number" },
sales12m: { type: "object", description: "Quantités vendues par mois, clés YYYYMM." },
stock12m: { type: "object", description: "Stock fin de mois, clés YYYYMM." },
sales12mByStore: { type: "object", description: "Ventes par magasin puis par mois." },
caReseau: { type: "number" },
qteReseau: { type: "number" },
nbMagasinsReseau: { type: "integer" },
caParMagasinReseau: { type: "number" },
margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
qteReseauByMonth: { type: ["object", "null"], description: "Quantités réseau par mois, clés YYYY-MM." },
},
},
},
},
paths: {
"/fournisseurs": {
get: {
summary: "Liste des fournisseurs, avec la fraîcheur de leur instantané de grille",
parameters: [
{ name: "search", in: "query", schema: { type: "string" } },
{
name: "withData",
in: "query",
schema: { type: "string", enum: ["0", "1"] },
description: "1 = uniquement les fournisseurs déjà présents dans l'instantané.",
},
],
responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
},
},
"/grid": {
get: {
summary: "Lignes de grille d'un fournisseur",
description: "Répond `202 not_ready` si le fournisseur n'a pas encore d'instantané.",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
{ name: "gamme", in: "query", schema: { type: "string" } },
{ name: "code1", in: "query", schema: { type: "string" } },
{ name: "code2", in: "query", schema: { type: "string" } },
{ name: "code3", in: "query", schema: { type: "string" } },
{ name: "search", in: "query", schema: { type: "string" }, description: "Libellé, codein, GTIN, référence ou code centrale." },
...sortParams,
...paginationParams,
],
responses: {
"200": { description: "OK" },
"202": { description: "Instantané pas encore calculé" },
"400": { description: "Paramètres invalides" },
"401": { description: "Non authentifié" },
},
},
},
"/products/search": {
get: {
summary: "Recherche transversale de produits, tous fournisseurs confondus",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 } },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint la recherche à un fournisseur." },
{ name: "gamme", in: "query", schema: { type: "string" } },
...sortParams,
...paginationParams,
],
responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
},
},
"/products/{codein}": {
get: {
summary: "Fiche complète d'un produit",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" } },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article multi-fournisseurs." },
],
responses: { "200": { description: "OK" }, "404": { description: "Inconnu de l'instantané" } },
},
},
"/network/{codeCentrale}": {
get: {
summary: "Métriques réseau Qlik en cache + courbe 12 mois",
parameters: [{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" } }],
responses: { "200": { description: "OK" }, "404": { description: "Absent du cache réseau" } },
},
},
},
};
return Response.json(spec);
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productDetailSchema } from "@/lib/api-schemas";
import { getGridRowByCodein } from "@/lib/grid-store";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/products/:codein?fournisseur=…
*
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
*
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
* sans lui, l'instantané le plus récent est renvoyé.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codein: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codein } = await ctx.params;
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const found = await getGridRowByCodein(codein, parsed.data.fournisseur);
if (!found) {
return fail(
"not_found",
`Aucun instantané pour le produit « ${codein} ». Le fournisseur a-t-il déjà été ouvert dans la Grille ?`,
);
}
// Métriques réseau à jour depuis le cache (le payload peut dater du dernier calcul).
let network = null;
if (found.row.codeCentrale) {
const metrics = await getNetworkMetricsByCodeCentrale([found.row.codeCentrale]);
network = metrics.get(found.row.codeCentrale) ?? null;
}
return ok(
{ ...found.row, network },
{ meta: { computedAt: found.computedAt, networkFetchedAt: network?.fetchedAt ?? null } },
);
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
*
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
* était impossible.
*
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
* Grille n'apparaît pas encore dans les résultats.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
const result = await queryGridRows({
codeFournisseur: q.fournisseur,
gamme: q.gamme,
search: q.q,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
return ok(
result.rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
meta: {
query: q.q,
computedAt: result.computedAt,
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
},
},
);
}
+76
View File
@@ -129,6 +129,82 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
fetchedAt: timestamp("fetched_at").defaultNow(),
});
/**
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
*
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
* arrête simplement de jeter le résultat.
*
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
* magasin (sales12mByStore, caByStore…).
*/
export const gridRows = pgTable("grid_rows", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
codeCentrale: varchar("code_centrale", { length: 20 }),
code1: varchar("code1", { length: 20 }),
code2: varchar("code2", { length: 20 }),
code3: varchar("code3", { length: 20 }),
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
nbMagasinsReseau: integer("nb_magasins_reseau"),
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
payload: jsonb("payload").notNull(),
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
computedAt: timestamp("computed_at").defaultNow().notNull(),
}, (table) => {
return [
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
index("idx_grid_rows_codein").on(table.codein),
index("idx_grid_rows_gamme").on(table.codeGamme),
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
index("idx_grid_rows_total_ca").on(table.totalCa),
index("idx_grid_rows_libelle").on(table.libelle1),
];
});
/**
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
*
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
*/
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
name: varchar("name", { length: 100 }).notNull(),
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
/** SHA-256 hexadécimal de la clé complète. */
keyHash: text("key_hash").notNull().unique(),
/** 'admin' ou 'user' — même sémantique que users.role. */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdBy: varchar("created_by", { length: 50 }),
createdAt: timestamp("created_at").defaultNow(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
+117
View File
@@ -0,0 +1,117 @@
"use server";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
*
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
* conserve sa ligne, pour garder la trace de son usage passé.
*/
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { generateApiKey } from "@/lib/api-auth";
import { revalidatePath } from "next/cache";
async function ensureAdmin() {
const session = await auth();
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
throw new Error("Accès refusé : Droits administrateur requis.");
}
return session;
}
export interface ApiKeyRow {
id: number;
name: string;
keyPrefix: string;
role: string;
createdBy: string | null;
createdAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
}
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise<ApiKeyRow[]> {
await ensureAdmin();
const rows = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
role: apiKeys.role,
createdBy: apiKeys.createdBy,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.orderBy(desc(apiKeys.createdAt));
return rows.map((r) => ({
...r,
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
}));
}
/**
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
*/
export async function createApiKey(
name: string,
role: "admin" | "user" = "user",
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
const session = await ensureAdmin();
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
try {
const { key, keyHash, keyPrefix } = generateApiKey();
await db.insert(apiKeys).values({
name: trimmed,
keyPrefix,
keyHash,
role,
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
});
revalidatePath("/settings");
return { success: true, key, keyPrefix };
} catch (err) {
console.error("[api-keys] création KO:", err);
return { success: false, error: "Erreur technique lors de la création de la clé." };
}
}
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] révocation KO:", err);
return { success: false, error: "Erreur lors de la révocation." };
}
}
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] suppression KO:", err);
return { success: false, error: "Erreur lors de la suppression." };
}
}
@@ -0,0 +1,217 @@
"use client";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1`.
*
* La clé générée n'est affichable qu'une fois : elle est mise en évidence après
* création, puis irrécupérable (seul son hachage est stocké côté base).
*/
import { useState, useEffect, useCallback } from "react";
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
export function ApiKeyManagement() {
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [freshKey, setFreshKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
// statement est un await. Cela évite une cascade de rendus au montage.
const refresh = useCallback(async () => {
try {
setKeys(await getApiKeys());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || creating) return;
setCreating(true);
setError(null);
const res = await createApiKey(name.trim(), role);
if (res.success) {
setFreshKey(res.key);
setName("");
setCopied(false);
await refresh();
} else {
setError(res.error);
}
setCreating(false);
};
const handleRevoke = async (id: number) => {
await revokeApiKey(id);
await refresh();
};
const handleDelete = async (id: number) => {
await deleteApiKey(id);
await refresh();
};
return (
<div className="space-y-4">
<p className="text-[12px] text-[var(--text-secondary)]">
Les clés donnent accès à l&apos;API de lecture <code className="font-mono">/api/v1</code> (grille,
recherche, métriques réseau) depuis un script ou un outil externe, via l&apos;en-tête{" "}
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
</p>
{/* Clé fraîchement créée — visible une seule fois */}
{freshKey && (
<div className="rounded-xl p-3.5 space-y-2"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
{freshKey}
</code>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
className="rounded-lg px-2.5 py-2 shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<button
type="button"
onClick={() => setFreshKey(null)}
className="text-[11px] underline"
style={{ color: "var(--text-muted)" }}
>
J&apos;ai copié la clé, masquer
</button>
</div>
)}
{/* Création */}
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
<input
value={name}
onChange={(e) => 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)" }}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as "admin" | "user")}
className="rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
<option value="user">Rôle : utilisateur</option>
<option value="admin">Rôle : administrateur</option>
</select>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Créer
</button>
</form>
{error && (
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
)}
{/* Liste */}
{loading ? (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
</div>
) : keys.length === 0 ? (
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
) : (
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
<span className="inline-flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
{k.name}
</span>
{k.revokedAt && (
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
Révoquée
</span>
)}
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{!k.revokedAt ? (
<button
onClick={() => handleRevoke(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--accent-error)" }}
title="Révoquer"
>
<Ban className="w-3.5 h-3.5" /> Révoquer
</button>
) : (
<button
onClick={() => handleDelete(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--text-muted)" }}
title="Supprimer définitivement"
>
<Trash2 className="w-3.5 h-3.5" /> Supprimer
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+22
View File
@@ -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<void> {
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<ProductRow[]> {
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
@@ -85,6 +103,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const promise = buildProductRows(input).then((rows) => {
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);
+122
View File
@@ -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<AuthContext | Response> {
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;
}
+67
View File
@@ -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<ApiErrorCode, number> = {
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<T>(
data: T,
opts: { pagination?: Pagination; meta?: Record<string, unknown> } = {},
): 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] },
);
}
+87
View File
@@ -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<ProductRow> {
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<string, unknown> = {};
for (const key of wanted) {
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
}
return out as Partial<ProductRow>;
}
/** Normalise le tri validé vers la clé attendue par grid-store. */
export function toSortKey(sort?: string): GridSortKey | undefined {
return sort as GridSortKey | undefined;
}
+274
View File
@@ -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<number> {
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<GridQueryResult> {
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<number>`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<number>`count(*)::int`,
computedAt: sql<Date | null>`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<string | null>`max(${gridRows.nomFournisseur})`,
rowCount: sql<number>`count(*)::int`,
computedAt: sql<Date | null>`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,
}));
}
+7
View File
@@ -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));