mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
17 files changed
+1509
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user