feat(api): API prête pour une IA externe type ChatGPT

Adapte l'API CollectFlow aux exigences des Actions ChatGPT et garantit que
les données soient réellement disponibles pour un consommateur externe.

Schéma OpenAPI :
- openapi.json devient PUBLIC (sans clé). Il ne décrit que la structure, sans
  aucune donnée, or ChatGPT importe le schéma par URL avant que la clé ne soit
  configurée : l'exiger rendait l'import impossible.
- servers[0].url est désormais ABSOLUE (une URL relative est rejetée à
  l'import), construite depuis l'hôte appelant ou COLLECTFLOW_PUBLIC_URL.
- operationId sur chaque opération (requis par les Actions), schémas de
  réponse typés, et descriptions rédigées pour le modèle : quelle gamme
  utiliser pour raisonner, pourquoi préférer caParMagasinReseau au CA brut,
  que faire d'un 202 not_ready.

Disponibilité des données :
- Nouveau préchauffage /api/admin/grid-warmup + bouton dans Paramètres.
  Sans lui, l'API ne sert que les fournisseurs déjà ouverts à la main dans la
  Grille — une IA externe n'aurait presque rien vu. Le job calcule tous les
  fournisseurs séquentiellement (paralléliser saturerait PostgreSQL), saute
  ceux à jour depuis moins de 24 h et suit son avancement.

Documentation :
- Paramètres → marche à suivre pas à pas pour brancher un GPT (import du
  schéma, auth par clé personnalisée X-API-Key), et mention de
  COLLECTFLOW_PUBLIC_URL quand le domaine public diffère.

L'assistant interne et son API api.ffnancy.fr ne sont pas touchés.

Vérifié sur PostgreSQL local : schéma servi sans clé en 200, URL absolue,
5 operationId, auth apiKey/X-API-Key, surcharge COLLECTFLOW_PUBLIC_URL
effective, 5 endpoints en 200 avec la clé, 401 JSON sans clé, et recherche
transversale renvoyant bien plusieurs fournisseurs.

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:21:31 +00:00
1 parent a45e09777b
commit 4b229acc9b
5 files changed
+529 -91

No files matched your search

@@ -9,7 +9,7 @@
*/
import { useEffect, useState } from "react";
import { Copy, Check, ExternalLink } from "lucide-react";
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -123,6 +123,37 @@ export function ApiConnectionInfo() {
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
<div className="space-y-2">
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
Connecter une IA externe (ChatGPT)
</p>
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
<li>Créez une clé d&apos;API dans la section ci-dessous et copiez-la.</li>
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
<li>
Cliquez sur <strong>Importer depuis une URL</strong> et collez l&apos;adresse du schéma :
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
<span style={{ color: "var(--text-muted)" }}>
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l&apos;importer.
Votre application doit être joignable depuis Internet.
</span>
</li>
<li>
Dans <strong>Authentification</strong>, choisissez <strong>Clé d&apos;API</strong>, type{" "}
<strong>Personnalisé</strong>, nom d&apos;en-tête <code className="font-mono">X-API-Key</code>,
et collez votre clé.
</li>
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
</ol>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Si votre domaine public diffère de celui affiché ici, renseignez la variable
d&apos;environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l&apos;URL
déclarée dans le schéma.
</p>
</div>
{/* Comportement à connaître */}
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
@@ -0,0 +1,148 @@
"use client";
/**
* CollectFlow — Préchauffage des données exposées par l'API.
*
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
* ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
* fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
* une bonne fois.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
interface WarmupState {
status: "idle" | "running" | "success" | "error";
total: number;
done: number;
skipped: number;
failed: number;
currentFournisseur?: string;
finishedAt?: string;
error?: string;
lastErrors: string[];
}
export function GridWarmup() {
const [state, setState] = useState<WarmupState | null>(null);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const running = state?.status === "running";
const fetchState = useCallback(async () => {
try {
const res = await fetch("/api/admin/grid-warmup");
if (res.ok) setState(await res.json());
} catch {
// Réseau instable : on retentera au tick suivant.
}
}, []);
// Récupère l'état au montage : un préchauffage peut déjà tourner.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchState(); }, [fetchState]);
// Suivi de l'avancement tant que le job tourne.
useEffect(() => {
if (!running) return;
let cancelled = false;
const tick = async () => {
await fetchState();
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
};
pollRef.current = setTimeout(tick, 2000);
return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
}, [running, fetchState]);
const start = async () => {
setError(null);
try {
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
const data = await res.json();
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
setState(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
return (
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
L&apos;API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
l&apos;application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
sautés, il est donc sans risque de le relancer.
</p>
<div className="flex items-center gap-2.5">
<button
onClick={start}
disabled={running}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
</button>
{state && state.total > 0 && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
{traites} / {state.total} ({pct} %)
</span>
)}
</div>
{running && state && (
<div className="space-y-1.5">
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
</div>
{state.currentFournisseur && (
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
En cours : {state.currentFournisseur}
</p>
)}
</div>
)}
{state?.status === "success" && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
<span>
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
<strong>{state.skipped}</strong> déjà à jour
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
</span>
</div>
)}
{state && state.failed > 0 && state.lastErrors.length > 0 && (
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
<ul className="mt-1 pl-4 list-disc space-y-0.5">
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
</ul>
</details>
)}
{(error || state?.error) && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{error ?? state?.error}</span>
</div>
)}
{state?.status === "idle" && (
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
<Database className="w-3.5 h-3.5" />
Aucun préchauffage lancé depuis le démarrage de l&apos;application.
</p>
)}
</div>
);
}