diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 3d833c6..3959369 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -5,28 +5,107 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; import { buildGridNetworkQlikDateFilter } from "@/lib/qlik-date-range"; -// Synchro potentiellement longue (extraction hypercube paginee). +// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). +// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au +// client tout de suite via le state job ci-dessous. export const maxDuration = 300; +// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a +// besoin d'un process persistant pour conserver l'état de job en mémoire. +export const runtime = "nodejs"; +// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST). +export const dynamic = "force-dynamic"; + +/** État d'un job de sync Qlik pour un fournisseur donné. */ +type QlikSyncStatus = "idle" | "running" | "success" | "error"; + +interface QlikSyncJob { + jobId: string; + fournisseur: string; + status: QlikSyncStatus; + requested: number; + fetched: number; + upserted: number; + periode?: string; + dateDebut?: string; + dateFin?: string; + startedAt: string; + finishedAt?: string; + error?: string; + message?: string; +} /** - * POST /api/qlik/sync?fournisseur=XXX - * Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale) - * et met a jour le cache local. Admin uniquement. + * Store en mémoire au niveau module. Une Map par fournisseur car on ne lance + * qu'une extraction à la fois par fournisseur. C'est volontairement simple : + * si le process redémarre, les jobs en cours sont perdus → GET renverra idle + * et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX. */ -export async function POST(req: NextRequest) { +const jobs = new Map(); + +/** Construit un jobId court et lisible. */ +function newJobId(): string { + return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** Sérialise un job pour la réponse HTTP. */ +function publicJob(job: QlikSyncJob) { + return { + jobId: job.jobId, + fournisseur: job.fournisseur, + status: job.status, + requested: job.requested, + fetched: job.fetched, + upserted: job.upserted, + periode: job.periode, + dateDebut: job.dateDebut, + dateFin: job.dateFin, + startedAt: job.startedAt, + finishedAt: job.finishedAt, + error: job.error, + message: job.message, + }; +} + +/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */ +function idleJob(fournisseur: string) { + return { + jobId: null, + fournisseur, + status: "idle" as const, + requested: 0, + fetched: 0, + upserted: 0, + periode: undefined, + dateDebut: undefined, + dateFin: undefined, + startedAt: undefined, + finishedAt: undefined, + error: undefined, + message: undefined, + }; +} + +/** + * Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null. + */ +async function requireAdmin(): Promise { const session = await auth(); - if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") { - return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - - const fournisseur = req.nextUrl.searchParams.get("fournisseur"); - if (!fournisseur) { - return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 }); + if ((session.user as { role?: string } | undefined)?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + return null; +} +/** + * Exécute réellement l'extraction + l'upsert, en mettant à jour le job + * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). + */ +async function runJob(job: QlikSyncJob): Promise { try { - // Codes centraux des articles de ce fournisseur - const articles = await pgGetArticlesByFournisseur(fournisseur); + const articles = await pgGetArticlesByFournisseur(job.fournisseur); const codes = [ ...new Set( articles @@ -34,38 +113,107 @@ export async function POST(req: NextRequest) { .filter(Boolean), ), ]; - console.log(`[api/qlik/sync] fournisseur=${fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`); + job.requested = codes.length; + console.log( + `[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`, + ); if (codes.length === 0) { - return NextResponse.json({ - success: true, - fournisseur, - fetched: 0, - upserted: 0, - message: "Aucun article avec code centrale pour ce fournisseur", - fetchedAt: new Date().toISOString(), - }); + job.status = "success"; + job.message = "Aucun article avec code centrale pour ce fournisseur"; + job.finishedAt = new Date().toISOString(); + return; } const dateFilter = buildGridNetworkQlikDateFilter(); - console.log(`[api/qlik/sync] → lancement extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`); + job.periode = dateFilter.label; + job.dateDebut = dateFilter.dateDebut; + job.dateFin = dateFilter.dateFin; + console.log( + `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, + ); const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); - console.log(`[api/qlik/sync] ← Qlik a renvoyé ${metrics.size} produits réseau`); + job.fetched = metrics.size; + console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`); const count = await upsertNetworkMetrics([...metrics.values()]); - console.log(`[api/qlik/sync] ${count} lignes upsert dans le cache`); - return NextResponse.json({ - success: true, - fournisseur, - requested: codes.length, - fetched: metrics.size, - upserted: count, - periode: dateFilter.label, - dateDebut: dateFilter.dateDebut, - dateFin: dateFilter.dateFin, - fetchedAt: new Date().toISOString(), - }); + job.upserted = count; + console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`); + job.status = "success"; + job.finishedAt = new Date().toISOString(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error("[api/qlik/sync]", msg); - return NextResponse.json({ success: false, error: msg }, { status: 500 }); + console.error(`[api/qlik/sync] job=${job.jobId}`, msg); + job.status = "error"; + job.error = msg; + job.finishedAt = new Date().toISOString(); } } + +/** + * GET /api/qlik/sync?fournisseur=XXX + * Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est + * connu, renvoie un état "idle" (avec un message explicite). Permet au + * frontend de reprendre l'état après un reload et de poller en arrière-plan. + */ +export async function GET(req: NextRequest) { + const denied = await requireAdmin(); + if (denied) return denied; + + const fournisseur = req.nextUrl.searchParams.get("fournisseur"); + if (!fournisseur) { + return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 }); + } + const job = jobs.get(fournisseur); + if (!job) { + return NextResponse.json({ success: true, ...idleJob(fournisseur) }); + } + return NextResponse.json({ success: true, ...publicJob(job) }); +} + +/** + * POST /api/qlik/sync?fournisseur=XXX + * Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan + * et retourne *immédiatement* { success, status, jobId, ... }. Le client doit + * ensuite poller GET pour suivre l'avancement et être notifié de la fin. + */ +export async function POST(req: NextRequest) { + const denied = await requireAdmin(); + if (denied) return denied; + + const fournisseur = req.nextUrl.searchParams.get("fournisseur"); + if (!fournisseur) { + return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 }); + } + + const existing = jobs.get(fournisseur); + if (existing && existing.status === "running") { + // Un job tourne déjà pour ce fournisseur : on le réutilise. + return NextResponse.json({ success: true, ...publicJob(existing) }); + } + + const job: QlikSyncJob = { + jobId: newJobId(), + fournisseur, + status: "running", + requested: 0, + fetched: 0, + upserted: 0, + startedAt: new Date().toISOString(), + }; + jobs.set(fournisseur, job); + console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`); + + // Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on + // renvoie tout de suite au client et il polle GET. Les erreurs sont + // capturées dans runJob et inscrites dans le job. + void runJob(job).catch((e) => { + // Filet de sécurité (runJob ne devrait pas throw, mais on ne veut + // jamais qu'une exception non capturée crashe le process). + const msg = e instanceof Error ? e.message : String(e); + console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg); + job.status = "error"; + job.error = msg; + job.finishedAt = new Date().toISOString(); + }); + + return NextResponse.json({ success: true, ...publicJob(job) }); +} diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index ba69c69..3b1172a 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react"; @@ -11,6 +11,23 @@ interface SyncQlikButtonProps { lastUpdate?: string | null; } +/** État serveur renvoyé par GET/POST /api/qlik/sync */ +interface SyncJobState { + jobId: string | null; + fournisseur: string; + status: "idle" | "running" | "success" | "error"; + requested: number; + fetched: number; + upserted: number; + periode?: string; + dateDebut?: string; + dateFin?: string; + startedAt?: string; + finishedAt?: string; + error?: string; + message?: string; +} + function formatDate(iso?: string | null): string { if (!iso) return "jamais"; const d = new Date(iso); @@ -21,34 +38,213 @@ function formatDate(iso?: string | null): string { /** * Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché * (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj. + * + * UX : le POST démarre un job serveur en arrière-plan et revient immédiatement. + * Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle + * GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de + * la page) et continuer à afficher le spinner correctement. */ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) { - const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle"); const [message, setMessage] = useState(""); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const pollTimerRef = useRef | null>(null); + /** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */ + const trackingRef = useRef(false); + + const clearPollTimer = useCallback(() => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + /** + * Met à jour le label visible à droite du bouton selon l'état courant. + */ + const applyJobToUi = useCallback((j: SyncJobState | null) => { + if (!j) return; + if (j.status === "running") { + setStatus("running"); + // Texte informatif pendant l'extraction + const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : ""; + setMessage(`Extraction Qlik en cours${requestedTxt}…`); + } else if (j.status === "success") { + setStatus("success"); + if (j.message) setMessage(j.message); + else setMessage(`${j.upserted} produits réseau synchronisés`); + } else if (j.status === "error") { + setStatus("error"); + setMessage(j.error || "Erreur sync Qlik"); + } else { + setStatus("idle"); + } + }, []); + + /** + * Force le refresh de la grille (avec cache-buster) et de la barre de résumé. + * C'est l'équivalent de ce que faisait l'ancien code après un POST réussi. + */ + const refreshGrid = useCallback(() => { + const params = new URLSearchParams(searchParams.toString()); + params.set("_refresh", String(Date.now())); + router.replace(`${pathname}?${params.toString()}`); + router.refresh(); + }, [pathname, router, searchParams]); + + /** + * Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job + * passe en success/error, ou après un timeout de sécurité. + */ + const pollOnce = useCallback(async (): Promise => { + if (!codeFournisseur) return false; + if (!trackingRef.current) return false; + try { + const res = await fetch( + `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, + { method: "GET", cache: "no-store" }, + ); + if (!res.ok) { + // En cas d'erreur HTTP transitoire on retente au tour suivant. + return true; + } + const data: SyncJobState = await res.json(); + applyJobToUi(data); + + if (data.status === "running") { + // Continue à poller + return true; + } + if (data.status === "success" || data.status === "error") { + // Terminé : on rafraîchit la grille, on coupe le polling. + trackingRef.current = false; + if (data.status === "success") { + refreshGrid(); + } + // Petit délai pour laisser l'utilisateur lire le message vert/rouge + setTimeout(() => { + setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); + }, 4000); + return false; + } + // idle : rien à faire + trackingRef.current = false; + return false; + } catch { + // Erreur réseau transitoire, on laisse le polling réessayer. + return true; + } + }, [applyJobToUi, codeFournisseur, refreshGrid]); + + /** + * Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut + * continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas + * marteler le serveur pendant une extraction qui dure plusieurs minutes. + */ + const startPolling = useCallback(() => { + clearPollTimer(); + const tick = async () => { + const keepGoing = await pollOnce(); + if (keepGoing && trackingRef.current) { + pollTimerRef.current = setTimeout(tick, 3000); + } else { + pollTimerRef.current = null; + } + }; + // Premier appel quasi immédiat + pollTimerRef.current = setTimeout(tick, 200); + }, [clearPollTimer, pollOnce]); + + /** + * Au mount : si on a un fournisseur, on interroge l'API pour savoir si + * un job tourne déjà (ex. reload de la page pendant l'extraction). Si + * oui, on reprend l'état et on relance le polling. + */ + useEffect(() => { + if (!codeFournisseur) return; + let cancelled = false; + trackingRef.current = false; + clearPollTimer(); + (async () => { + try { + const res = await fetch( + `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, + { method: "GET", cache: "no-store" }, + ); + if (cancelled) return; + if (!res.ok) return; + const data: SyncJobState = await res.json(); + if (cancelled) return; + if (data.status === "running" && data.jobId) { + trackingRef.current = true; + applyJobToUi(data); + startPolling(); + } else if (data.status === "success" || data.status === "error") { + // Job terminé en amont (avant le mount) : on l'affiche brièvement + applyJobToUi(data); + setTimeout(() => { + setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); + }, 4000); + } + } catch { + /* ignore : on n'empêche pas l'UI d'afficher le bouton */ + } + })(); + return () => { + cancelled = true; + trackingRef.current = false; + clearPollTimer(); + }; + // On veut interroger une seule fois au mount (et quand le fournisseur change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeFournisseur, clearPollTimer]); + + /** + * Cleanup : annule un timer de polling éventuel au démontage. + */ + useEffect(() => { + return () => { + clearPollTimer(); + }; + }, [clearPollTimer]); + const handleSync = async () => { if (!codeFournisseur) { setStatus("error"); setMessage("Aucun fournisseur sélectionné"); + setTimeout(() => setStatus("idle"), 6000); return; } - setStatus("loading"); - setMessage(""); + // Empêche un double-clic pendant qu'un job tourne déjà. + if (status === "running") return; + + setStatus("running"); + setMessage("Démarrage de l'extraction Qlik…"); try { - const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" }); - const data = await res.json(); - if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`); - setStatus("success"); - setMessage(`${data.upserted} produits réseau synchronisés`); - // Bump _refresh → grid-client refetch /api/grid/rows avec refresh=1 (forceRefresh, - // bypass cache 10 min de getProductRows) pour afficher les nouvelles métriques réseau. - const params = new URLSearchParams(searchParams.toString()); - params.set("_refresh", String(Date.now())); - router.replace(`${pathname}?${params.toString()}`); - router.refresh(); + const res = await fetch( + `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, + { method: "POST" }, + ); + const data: SyncJobState & { success?: boolean } = await res.json(); + if (!res.ok || (data as { success?: boolean }).success === false) { + throw new Error(data.error || `HTTP ${res.status}`); + } + // Si le serveur nous dit que c'est déjà en cours (jobId existant), + // on reprend juste le suivi. + if (data.status === "running" && data.jobId) { + trackingRef.current = true; + applyJobToUi(data); + startPolling(); + return; + } + // Status terminal direct (cas rare : 0 codes → success immédiat) + applyJobToUi(data); + if (data.status === "success") { + refreshGrid(); + } setTimeout(() => setStatus("idle"), 4000); } catch (e) { setStatus("error"); @@ -62,16 +258,21 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr - {status === "error" ? message : `MAJ Qlik · ${formatDate(lastUpdate)}`} + {status === "error" + ? message + : status === "running" + ? (message || `Extraction Qlik…`) + : `MAJ Qlik · ${formatDate(lastUpdate)}`} );