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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user