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

+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;
}