-
setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
s.setUser);
const setPassword = useDbSettingsStore((s) => s.setPassword);
const setSsl = useDbSettingsStore((s) => s.setSsl);
+ const setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre);
const reloadFromServer = useCallback(async (force = false) => {
- const config = await loadSavedConfig(force);
- if (config?.url) {
- // Parser l'URL pour remettre dans le store
- try {
- const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
- setHost(url.hostname);
- setPort(url.port || "5432");
- setDatabase(url.pathname.slice(1).split("?")[0]);
- setUser(url.username);
- setPassword(decodeURIComponent(url.password));
- setSsl(config.url.includes("sslmode=require"));
- } catch (e) {
- console.error("Failed to parse saved URL", e);
- }
+ const { db } = await loadSavedConfig(force);
+ // Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré.
+ setPassword("");
+ setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false);
+ if (db) {
+ setHost(db.host);
+ setPort(db.port);
+ setDatabase(db.database);
+ setUser(db.user);
+ setSsl(db.ssl);
}
- }, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
+ }, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
index e0fbfa6..b6997ea 100644
--- a/src/app/(dashboard)/settings/page.tsx
+++ b/src/app/(dashboard)/settings/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+import { verifierAdmin } from "@/lib/authz";
import { ParametresClient } from "./client";
export const metadata: Metadata = { title: "Paramètres" };
@@ -11,6 +13,9 @@ export const metadata: Metadata = { title: "Paramètres" };
* lien se partage et survive au rechargement, puis rend la page cliente.
*/
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
+ // Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
+ if (!(await verifierAdmin()).ok) redirect("/dashboard");
+
const { onglet } = await props.searchParams;
return ;
}
diff --git a/src/app/api/admin/grid-warmup/route.ts b/src/app/api/admin/grid-warmup/route.ts
index 3a8f94b..6e56bbd 100644
--- a/src/app/api/admin/grid-warmup/route.ts
+++ b/src/app/api/admin/grid-warmup/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { adminOuReponse } from "@/lib/authz";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { listGridSuppliers } from "@/lib/grid-store";
@@ -45,15 +45,6 @@ function publicJob(j: WarmupJob | null) {
return j;
}
-async function requireAdmin(): Promise {
- const session = await auth();
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if ((session.user as { role?: string } | undefined)?.role !== "admin") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- return null;
-}
-
async function runWarmup(j: WarmupJob, staleHours: number): Promise {
try {
const fournisseurs = await pgGetFournisseurs();
@@ -102,8 +93,8 @@ async function runWarmup(j: WarmupJob, staleHours: number): Promise {
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
export async function GET() {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
return NextResponse.json({ success: true, ...publicJob(job) });
}
@@ -112,8 +103,8 @@ export async function GET() {
* Démarre le préchauffage en arrière-plan et répond immédiatement.
*/
export async function POST(req: NextRequest) {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
if (job?.status === "running") {
return NextResponse.json({ success: true, ...publicJob(job) });
diff --git a/src/app/api/admin/sync/fournisseurs/route.ts b/src/app/api/admin/sync/fournisseurs/route.ts
index aa20fbd..292557e 100644
--- a/src/app/api/admin/sync/fournisseurs/route.ts
+++ b/src/app/api/admin/sync/fournisseurs/route.ts
@@ -1,22 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { asc, eq, inArray, sql } from "drizzle-orm";
-import { auth } from "@/lib/auth";
+import { adminOuReponse } from "@/lib/authz";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-async function requireAdmin(): Promise {
- const session = await auth();
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if ((session.user as { role?: string } | undefined)?.role !== "admin") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- return null;
-}
-
/**
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
* dernière synchronisation.
@@ -25,8 +16,8 @@ async function requireAdmin(): Promise {
* besoin de la vue d'ensemble pour décider qui cadencer.
*/
export async function GET() {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
const rows = await db
.select()
@@ -65,8 +56,8 @@ const patchSchema = z.object({
* trompeur, alors même qu'il vient d'être rouvert volontairement.
*/
export async function PATCH(req: NextRequest) {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
let body: unknown;
try {
diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts
index ab79f45..9989685 100644
--- a/src/app/api/admin/sync/route.ts
+++ b/src/app/api/admin/sync/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { auth } from "@/lib/auth";
+import { adminOuReponse } from "@/lib/authz";
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
import {
getSyncSchedulerState,
@@ -15,19 +15,10 @@ export const dynamic = "force-dynamic";
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
export const maxDuration = 300;
-async function requireAdmin(): Promise {
- const session = await auth();
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if ((session.user as { role?: string } | undefined)?.role !== "admin") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- return null;
-}
-
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
export async function GET() {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
const settings = await readSyncSettings();
const maintenant = new Date();
@@ -55,8 +46,8 @@ const settingsSchema = z.object({
/** PATCH — met à jour les réglages. */
export async function PATCH(req: NextRequest) {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
let body: unknown;
try {
@@ -87,8 +78,8 @@ const actionSchema = z.object({
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
export async function POST(req: NextRequest) {
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
let body: unknown;
try {
diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts
index 2f01fae..8a7cf2b 100644
--- a/src/app/api/commandes-auto/route.ts
+++ b/src/app/api/commandes-auto/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+import { sessionOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
@@ -9,6 +10,9 @@ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
* Transfère les query params site et codefou si présents.
*/
export async function GET(req: NextRequest) {
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
try {
const { searchParams } = req.nextUrl;
const params = new URLSearchParams();
diff --git a/src/app/api/diag/route.ts b/src/app/api/diag/route.ts
index e88c32d..6f6c796 100644
--- a/src/app/api/diag/route.ts
+++ b/src/app/api/diag/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
+import { adminOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
@@ -31,6 +32,9 @@ async function probe(url: string) {
}
export async function GET(req: NextRequest) {
+ const acces = await adminOuReponse();
+ if (acces instanceof Response) return acces;
+
const { searchParams } = new URL(req.url);
// Date range: 12 derniers mois
@@ -55,7 +59,7 @@ export async function GET(req: NextRequest) {
if (!firstCodein) {
try {
- const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
+ const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
if (list.length > 0) firstCodein = (list[0] as Record).codein as string ?? null;
@@ -80,13 +84,13 @@ export async function GET(req: NextRequest) {
referentiel_forced,
] = await Promise.all([
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
- probe(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=2`),
- probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${codefou}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
+ probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
+ probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
// Test mensuel avec no_id auto-résolu
firstCodein
? (async () => {
try {
- const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
+ const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const noid = list[0] ? (list[0] as Record).no_id as string : null;
@@ -99,7 +103,7 @@ export async function GET(req: NextRequest) {
firstCodein
? (async () => {
try {
- const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=5`, { cache: "no-store" });
+ const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const art = (list[4] ?? list[0]) as Record | undefined;
diff --git a/src/app/api/diag/stock-fournisseur/route.ts b/src/app/api/diag/stock-fournisseur/route.ts
index d5b7cfc..44aa7c4 100644
--- a/src/app/api/diag/stock-fournisseur/route.ts
+++ b/src/app/api/diag/stock-fournisseur/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
+import { adminOuReponse } from "@/lib/authz";
export const dynamic = "force-dynamic";
@@ -13,6 +14,9 @@ export const dynamic = "force-dynamic";
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
*/
export async function GET() {
+ const acces = await adminOuReponse();
+ if (acces instanceof Response) return acces;
+
try {
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
} catch (e) {
diff --git a/src/app/api/export/excel/route.ts b/src/app/api/export/excel/route.ts
index df6dab5..7eace0c 100644
--- a/src/app/api/export/excel/route.ts
+++ b/src/app/api/export/excel/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
+import { sessionOuReponse } from "@/lib/authz";
const ExportSchema = z.object({
nomFournisseur: z.string(),
@@ -19,6 +20,9 @@ const ExportSchema = z.object({
});
export async function POST(req: NextRequest) {
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
diff --git a/src/app/api/export/modified-gammes/route.ts b/src/app/api/export/modified-gammes/route.ts
index 0b838b2..600817f 100644
--- a/src/app/api/export/modified-gammes/route.ts
+++ b/src/app/api/export/modified-gammes/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
+import { sessionOuReponse } from "@/lib/authz";
const ExportModifiedGammesSchema = z.object({
nomFournisseur: z.string(),
@@ -13,6 +14,9 @@ const ExportModifiedGammesSchema = z.object({
});
export async function POST(req: NextRequest) {
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
const body = await req.json();
const parsed = ExportModifiedGammesSchema.safeParse(body);
if (!parsed.success) {
diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts
index 333627a..02dbe50 100644
--- a/src/app/api/ff-status/route.ts
+++ b/src/app/api/ff-status/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
-import { testFfApiConnection } from "@/features/settings/actions";
+import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
+import { sessionOuReponse } from "@/lib/authz";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -13,7 +14,10 @@ export const dynamic = "force-dynamic";
* détail, impossible à distinguer d'une simple erreur d'URL.
*/
export async function GET() {
- const res = await testFfApiConnection();
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
+ const res = await diagnostiquerApiFf();
if (!res.success) {
return NextResponse.json(
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
diff --git a/src/app/api/grid/rows/route.ts b/src/app/api/grid/rows/route.ts
index a1938e7..a12e9d6 100644
--- a/src/app/api/grid/rows/route.ts
+++ b/src/app/api/grid/rows/route.ts
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { getProductRows } from "@/features/grid/api/get-product-rows";
+import { sessionOuReponse } from "@/lib/authz";
import type { GridFilters } from "@/types/grid";
export const dynamic = "force-dynamic";
@@ -11,6 +12,9 @@ function writeNdjson(controller: ReadableStreamDefaultController, va
}
export async function GET(request: NextRequest) {
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
const searchParams = request.nextUrl.searchParams;
const codeFournisseur = searchParams.get("fournisseur");
diff --git a/src/app/api/grid/rows/store-patch/route.ts b/src/app/api/grid/rows/store-patch/route.ts
new file mode 100644
index 0000000..35b2263
--- /dev/null
+++ b/src/app/api/grid/rows/store-patch/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest } from "next/server";
+import { getStorePatch } from "@/features/grid/api/get-product-rows";
+import { MAGASINS } from "@/lib/magasins";
+import { sessionOuReponse } from "@/lib/authz";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
+ *
+ * Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
+ * La Grille charge toujours les lignes « tous magasins » et applique ce
+ * complément à son arrivée : le changement de magasin n'attend plus rien.
+ */
+export async function GET(request: NextRequest) {
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
+
+ const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
+ const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
+ // Un code inconnu ferait de chaque article un candidat : autant d'appels
+ // inutiles à l'API FF. « Tous magasins » n'a pas de complément.
+ if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
+ return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
+ }
+
+ try {
+ const patch = await getStorePatch(codeFournisseur, magasin);
+ return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
+ } catch (error) {
+ console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
+ return Response.json({ error: "Complément indisponible." }, { status: 502 });
+ }
+}
diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts
index 406e5a9..56adf2d 100644
--- a/src/app/api/logs/route.ts
+++ b/src/app/api/logs/route.ts
@@ -11,24 +11,15 @@
*/
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { adminOuReponse } from "@/lib/authz";
import { listCaptures, readCapture } from "@/lib/log-capture";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-async function requireAdmin(): Promise {
- const session = await auth();
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if ((session.user as { role?: string } | undefined)?.role !== "admin") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- return null;
-}
-
export async function GET(req: NextRequest) {
- const refus = await requireAdmin();
- if (refus) return refus;
+ const refus = await adminOuReponse();
+ if (refus instanceof Response) return refus;
const id = req.nextUrl.searchParams.get("id");
if (!id) {
diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts
index e01ab12..9d2ecea 100644
--- a/src/app/api/produits/opportunites/route.ts
+++ b/src/app/api/produits/opportunites/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { sessionOuReponse } from "@/lib/authz";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
@@ -12,14 +12,10 @@ export const dynamic = "force-dynamic";
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
- *
- * Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
- const session = await auth();
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const acces = await sessionOuReponse();
+ if (acces instanceof Response) return acces;
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts
index dae34e0..d282d95 100644
--- a/src/app/api/produits/search/route.ts
+++ b/src/app/api/produits/search/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { sessionOuReponse } from "@/lib/authz";
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
@@ -21,23 +21,18 @@ export const dynamic = "force-dynamic";
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
*
- * Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
+ * Contrôle de session explicite en plus du middleware : il répond ici en JSON,
+ * et relit le compte en base (cf. lib/authz).
*/
-async function requireSession(): Promise {
- const session = await auth();
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- return null;
-}
-
function lireTerme(req: NextRequest): string | null {
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
return q.length >= 3 ? q : null;
}
export async function POST(req: NextRequest) {
- const denied = await requireSession();
- if (denied) return denied;
+ const denied = await sessionOuReponse();
+ if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
@@ -54,8 +49,8 @@ export async function POST(req: NextRequest) {
}
export async function GET(req: NextRequest) {
- const denied = await requireSession();
- if (denied) return denied;
+ const denied = await sessionOuReponse();
+ if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts
index 53cf5a0..b223f63 100644
--- a/src/app/api/qlik/sync/route.ts
+++ b/src/app/api/qlik/sync/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
@@ -121,34 +121,6 @@ function idleJob(fournisseur: string, codeCentrale?: string) {
};
}
-/**
- * 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) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
- if ((session.user as { role?: string } | undefined)?.role !== "admin") {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- return null;
-}
-
-/**
- * Vérifie qu'une session existe (sans exiger le rôle admin).
- *
- * Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
- * `/api/*`, le contrôle doit donc être fait ici explicitement.
- */
-async function requireSession(): Promise {
- const session = await auth();
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
- return null;
-}
-
/**
* Regex de validation d'un code centrale "raisonnable".
*
@@ -337,8 +309,8 @@ export async function GET(req: NextRequest) {
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
- const denied = await requireSession();
- if (denied) return denied;
+ const denied = await sessionOuReponse();
+ if (denied instanceof Response) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
@@ -347,8 +319,8 @@ export async function GET(req: NextRequest) {
}
// Mode fournisseur : admin uniquement (inchangé).
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
@@ -371,8 +343,8 @@ export async function POST(req: NextRequest) {
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
- const denied = await requireSession();
- if (denied) return denied;
+ const denied = await sessionOuReponse();
+ if (denied instanceof Response) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
@@ -414,8 +386,8 @@ export async function POST(req: NextRequest) {
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
- const denied = await requireAdmin();
- if (denied) return denied;
+ const denied = await adminOuReponse();
+ if (denied instanceof Response) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
diff --git a/src/app/api/test-db/route.ts b/src/app/api/test-db/route.ts
index c40be64..35dbc20 100644
--- a/src/app/api/test-db/route.ts
+++ b/src/app/api/test-db/route.ts
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
import { db } from '@/db';
import { sql } from 'drizzle-orm';
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
+import { adminOuReponse } from '@/lib/authz';
export async function GET(request: Request) {
+ const acces = await adminOuReponse();
+ if (acces instanceof Response) return acces;
+
try {
const { searchParams } = new URL(request.url);
const codein = searchParams.get('codein') || '334152';
@@ -24,7 +28,9 @@ export async function GET(request: Request) {
stock: Object.fromEntries(map),
fou: fouResult.rows,
});
- } catch (e: any) {
- return NextResponse.json({ error: e.message, stack: e.stack }, { status: 500 });
+ } catch (e) {
+ // Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
+ console.error('[api/test-db]', e);
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
diff --git a/src/features/admin/api/api-key-actions.ts b/src/features/admin/api/api-key-actions.ts
index a27a7b3..4277f26 100644
--- a/src/features/admin/api/api-key-actions.ts
+++ b/src/features/admin/api/api-key-actions.ts
@@ -11,18 +11,10 @@
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
-import { auth } from "@/lib/auth";
+import { requireAdmin } from "@/lib/authz";
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;
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise {
- await ensureAdmin();
+ await requireAdmin();
const rows = await db
.select({
id: apiKeys.id,
@@ -67,7 +59,7 @@ 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 moi = await requireAdmin();
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
@@ -80,7 +72,7 @@ export async function createApiKey(
keyPrefix,
keyHash,
role,
- createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
+ createdBy: moi.username || null,
});
revalidatePath("/settings");
return { success: true, key, keyPrefix };
@@ -92,7 +84,7 @@ export async function createApiKey(
/** 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();
+ await requireAdmin();
try {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
revalidatePath("/settings");
@@ -105,7 +97,7 @@ export async function revokeApiKey(id: number): Promise<{ success: boolean; erro
/** 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();
+ await requireAdmin();
try {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
revalidatePath("/settings");
diff --git a/src/features/admin/api/user-actions.ts b/src/features/admin/api/user-actions.ts
index 2d1044b..f7a4e89 100644
--- a/src/features/admin/api/user-actions.ts
+++ b/src/features/admin/api/user-actions.ts
@@ -4,24 +4,14 @@ import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { hashPassword } from "@/features/auth/logic/auth-logic";
-import { auth } from "@/lib/auth";
+import { requireAdmin } from "@/lib/authz";
import { revalidatePath } from "next/cache";
-/**
- * Vérifie si l'utilisateur actuel est un administrateur.
- */
-async function ensureAdmin() {
- const session = await auth();
- if ((session?.user as any)?.role !== "admin") {
- throw new Error("Accès refusé : Droits administrateur requis.");
- }
-}
-
/**
* Récupère tous les utilisateurs (Admin seulement).
*/
export async function getUsers() {
- await ensureAdmin();
+ await requireAdmin();
return db.select({
id: users.id,
username: users.username,
@@ -34,7 +24,7 @@ export async function getUsers() {
* Crée un nouvel utilisateur.
*/
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
- await ensureAdmin();
+ await requireAdmin();
try {
await db.insert(users).values({
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
* Supprime un utilisateur.
*/
export async function deleteUser(id: number) {
- await ensureAdmin();
-
- const session = await auth();
- if (Number((session?.user as any)?.id) === id) {
+ const moi = await requireAdmin();
+ if (Number(moi.id) === id) {
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
}
@@ -65,7 +53,7 @@ export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id));
revalidatePath("/settings");
return { success: true };
- } catch (err) {
+ } catch {
return { success: false, error: "Erreur lors de la suppression." };
}
}
@@ -74,13 +62,13 @@ export async function deleteUser(id: number) {
* Met à jour le mot de passe d'un utilisateur.
*/
export async function updatePassword(id: number, newPassword: string) {
- await ensureAdmin();
+ await requireAdmin();
try {
await db.update(users)
.set({ passwordHash: hashPassword(newPassword) })
.where(eq(users.id, id));
return { success: true };
- } catch (err) {
+ } catch {
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
}
}
diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts
index fee76dd..09ae497 100644
--- a/src/features/auth/logic/seed-admin.ts
+++ b/src/features/auth/logic/seed-admin.ts
@@ -1,15 +1,72 @@
import { db } from "@/db";
import { users } from "@/db/schema";
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
-import { count, eq, sql } from "drizzle-orm";
+import { sql } from "drizzle-orm";
+
+/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
+let amorcage: Promise | null = null;
/**
- * Checks if the users table is empty.
- * If so, creates a default admin account (admin/admin).
- * Also ensures the table exists (auto-repair).
+ * Prépare les comptes au premier démarrage, une seule fois par processus.
+ *
+ * - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
+ * compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
+ * renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
+ * `data/users.json` n'est pas touché.
+ * - Base injoignable (première installation, base pas encore configurée) : on
+ * s'assure que le fichier de secours contient un administrateur, et on
+ * retentera la base à la prochaine connexion.
+ *
+ * Renvoie true si le compte admin/admin vient d'être créé en base.
*/
-export async function ensureAdminExists() {
- // 1. Always ensure local JSON fallback has at least one admin
+export function ensureAdminExists(): Promise {
+ if (!amorcage) {
+ amorcage = amorcer().catch((err: unknown) => {
+ // Échec : on retentera à la prochaine connexion.
+ amorcage = null;
+ console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
+ return false;
+ });
+ }
+ return amorcage;
+}
+
+async function amorcer(): Promise {
+ console.log("[AUTH] Testing database connection for seeding...");
+ try {
+ await db.execute(sql`SELECT 1`);
+ } catch (err) {
+ assurerAdminDeSecours();
+ throw err;
+ }
+
+ // Auto-repair: Ensure table exists
+ await db.execute(sql`
+ CREATE TABLE IF NOT EXISTS "users" (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'user',
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+ `);
+
+ const [{ nb }] = await db.select({ nb: sql`count(*)::int` }).from(users);
+ if (nb > 0) return false;
+
+ console.log("[AUTH] Table users vide : création du compte admin/admin.");
+ // Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
+ // l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
+ const crees = await db.insert(users).values({
+ username: "admin",
+ passwordHash: hashPassword("admin"),
+ role: "admin",
+ }).onConflictDoNothing().returning({ id: users.id });
+ return crees.length > 0;
+}
+
+/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
+function assurerAdminDeSecours() {
const fallbackUsers = getFallbackUsers();
if (fallbackUsers.length === 0) {
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
@@ -20,39 +77,4 @@ export async function ensureAdminExists() {
role: "admin"
}]);
}
-
- try {
- console.log("[AUTH] Testing database connection for seeding...");
- await db.execute(sql`SELECT 1`);
-
- // 2. Auto-repair: Ensure table exists
- await db.execute(sql`
- CREATE TABLE IF NOT EXISTS "users" (
- id SERIAL PRIMARY KEY,
- username VARCHAR(50) NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role VARCHAR(20) NOT NULL DEFAULT 'user',
- created_at TIMESTAMP DEFAULT NOW()
- );
- `);
-
- // 3. Sync admin to DB
- const adminUser = await db.select()
- .from(users)
- .where(eq(users.username, "admin"))
- .limit(1);
-
- if (adminUser.length === 0) {
- console.log("[AUTH] Seeding admin to DB...");
- await db.insert(users).values({
- username: "admin",
- passwordHash: hashPassword("admin"),
- role: "admin"
- });
- return true;
- }
- } catch (err: any) {
- console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message);
- }
- return false;
}
diff --git a/src/features/commandes-auto/actions.ts b/src/features/commandes-auto/actions.ts
index 8ce3c52..31aa863 100644
--- a/src/features/commandes-auto/actions.ts
+++ b/src/features/commandes-auto/actions.ts
@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { commandeCadences } from "@/db/schema";
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
+import { requireSession, verifierSession } from "@/lib/authz";
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
@@ -37,6 +38,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
+ await requireSession();
return getFournisseursCached();
}
@@ -45,6 +47,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
* de la dernière réception (FF Nancy).
*/
export async function listCadences(): Promise {
+ await requireSession();
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map();
try {
@@ -95,6 +98,9 @@ export interface UpsertCadenceInput {
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { ok: false, error: acces.message };
+
const codefou = input.codefou?.trim();
const site = input.site?.trim();
const intervalle = Math.round(Number(input.intervalleSemaines));
@@ -132,7 +138,10 @@ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: bo
}
/** Active / désactive une cadence sans la supprimer. */
-export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> {
+export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { ok: false, error: acces.message };
+
try {
await db
.update(commandeCadences)
@@ -147,7 +156,10 @@ export async function toggleCadence(codefou: string, site: string, actif: boolea
}
/** Supprime définitivement une cadence. */
-export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> {
+export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { ok: false, error: acces.message };
+
try {
await db
.delete(commandeCadences)
diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts
index cc97c8d..fd71e8a 100644
--- a/src/features/grid/actions.ts
+++ b/src/features/grid/actions.ts
@@ -3,12 +3,14 @@
import { getSitesFromApi } from "@/lib/api-ff-client";
import { getFournisseursCached } from "@/lib/ff-cache";
import { getProductRows } from "./api/get-product-rows";
+import { requireSession } from "@/lib/authz";
import type { ProductRow, GridFilters } from "@/types/grid";
/**
* Get the list of all suppliers from PostgreSQL (fouadr1).
*/
export async function getFournisseurs() {
+ await requireSession();
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
return getFournisseursCached();
@@ -18,6 +20,7 @@ export async function getFournisseurs() {
* Get the list of all stores (sites) from the FF Nancy API.
*/
export async function getMagasins() {
+ await requireSession();
return getSitesFromApi();
}
@@ -26,6 +29,7 @@ export async function getMagasins() {
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
+ await requireSession();
return {};
}
@@ -37,5 +41,6 @@ export async function getGridData(
magasin: string = "TOTAL",
filters?: Partial
): Promise {
+ await requireSession();
return getProductRows({ codeFournisseur, magasin, filters });
}
diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts
index a82e6bd..f609208 100644
--- a/src/features/grid/api/get-product-rows.ts
+++ b/src/features/grid/api/get-product-rows.ts
@@ -19,6 +19,13 @@ import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
+import {
+ applyStorePatchInPlace,
+ buildStorePatchEntries,
+ storePatchCandidates,
+ type StorePatch,
+ type StorePatchEntry,
+} from "@/features/grid/lib/store-patch";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -568,28 +575,27 @@ async function buildProductRows(input: GetProductRowsInput): Promise {
- if (!row.noid) return false;
- const storeQty = row.quantiteByStore?.[magasin] ?? 0;
- return storeQty === 0;
- });
-
- if (candidates.length === 0) return;
+ periods: string[],
+): Promise {
+ const candidates = storePatchCandidates(rows, magasin);
+ if (candidates.length === 0) return [];
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
- const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
+ const PLAFOND = plafondComplement();
const retenus = candidates.slice(0, PLAFOND);
if (candidates.length > retenus.length) {
console.warn(
@@ -598,88 +604,31 @@ async function reconcileSelectedStoreFromMensuelApi(
);
}
+ const mensuelMap = await getMensuelByArticles(
+ retenus.map((row) => ({
+ codein: row.codein,
+ libelle1: row.libelle1,
+ codefou: row.codeFournisseur,
+ noid: row.noid,
+ })),
+ dateDebut,
+ dateFin,
+ 25
+ );
+ return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
+}
+
+async function reconcileSelectedStoreFromMensuelApi(
+ rows: ProductRow[],
+ magasin: string,
+ dateDebut: string,
+ dateFin: string,
+ sortedPeriods: string[]
+) {
+ if (magasin === "TOTAL") return;
try {
- const mensuelMap = await getMensuelByArticles(
- retenus.map((row) => ({
- codein: row.codein,
- libelle1: row.libelle1,
- codefou: row.codeFournisseur,
- noid: row.noid,
- })),
- dateDebut,
- dateFin,
- 25
- );
-
- let fixedRows = 0;
- for (const row of retenus) {
- const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
- if (entries.length === 0) continue;
-
- const byPeriod = new Map();
- for (const entry of entries) {
- const period = entry.mois.replace("-", "");
- if (!sortedPeriods.includes(period)) continue;
- // qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
- // les nie au lieu de Math.abs pour que les retours restent déduits.
- const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
- const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
- const marge = Number(entry.ventes?.marge ?? 0) || 0;
- const stock = Number(entry.stock_fin_mois ?? 0) || 0;
- const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
- byPeriod.set(period, { qty, ca, marge, stock, receptions });
- }
-
- const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
- if (apiQty === 0) continue;
-
- row.sales12mByStore ??= {};
- row.stock12mByStore ??= {};
- row.receptions12mByStore ??= {};
- row.caByStore ??= {};
- row.quantiteByStore ??= {};
- row.margeByStore ??= {};
- row.sales12mByStore[magasin] ??= {};
- row.stock12mByStore[magasin] ??= {};
- row.receptions12mByStore[magasin] ??= {};
-
- let storeQty = 0;
- let storeCa = 0;
- let storeMarge = 0;
- let lastStock = 0;
-
- for (const period of sortedPeriods) {
- const value = byPeriod.get(period);
- const currentQty = row.sales12mByStore[magasin][period] ?? 0;
- const nextQty = value?.qty ?? 0;
- const deltaQty = nextQty - currentQty;
-
- row.sales12mByStore[magasin][period] = nextQty;
- row.receptions12mByStore[magasin][period] = value?.receptions ?? 0;
- if (value) lastStock = value.stock;
- row.stock12mByStore[magasin][period] = lastStock;
-
- row.sales12m[period] = (row.sales12m[period] ?? 0) + deltaQty;
- storeQty += nextQty;
- storeCa += value?.ca ?? 0;
- storeMarge += value?.marge ?? 0;
- }
-
- const deltaTotalQty = storeQty - (row.quantiteByStore[magasin] ?? 0);
- const deltaTotalCa = storeCa - (row.caByStore[magasin] ?? 0);
- const deltaTotalMarge = storeMarge - (row.margeByStore[magasin] ?? 0);
-
- row.quantiteByStore[magasin] = storeQty;
- row.caByStore[magasin] = storeCa;
- row.margeByStore[magasin] = storeMarge;
- row.totalQuantite += deltaTotalQty;
- row.totalCa += deltaTotalCa;
- row.totalMarge += deltaTotalMarge;
- row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
- if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
- fixedRows++;
- }
-
+ const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
+ const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
if (fixedRows > 0) {
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
}
@@ -688,6 +637,46 @@ async function reconcileSelectedStoreFromMensuelApi(
}
}
+/**
+ * Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
+ * cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
+ * enregistrement), sans autre invalidation à gérer.
+ */
+const storePatches = new WeakMap>>();
+
+/**
+ * Complément de l'API FF pour un magasin, calculé sur les lignes « tous
+ * magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
+ * recharger les lignes. Les demandes simultanées partagent le même calcul, et
+ * un échec n'est jamais gardé.
+ */
+export async function getStorePatch(codeFournisseur: string, magasin: string): Promise {
+ const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
+ const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
+ ? enCache.rows
+ : await getProductRows({ codeFournisseur, magasin: "TOTAL" });
+
+ let parMagasin = storePatches.get(rows);
+ if (!parMagasin) {
+ parMagasin = new Map();
+ storePatches.set(rows, parMagasin);
+ }
+ let patch = parMagasin.get(magasin);
+ if (!patch) {
+ const periods = last12Periods();
+ const { dateDebut, dateFin } = buildLast12MonthsRange();
+ const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
+ .then((entries): StorePatch => ({ magasin, periods, entries }));
+ const table = parMagasin;
+ enCours.catch(() => {
+ if (table.get(magasin) === enCours) table.delete(magasin);
+ });
+ parMagasin.set(magasin, enCours);
+ patch = enCours;
+ }
+ return patch;
+}
+
/**
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
*
diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts
index 0a837b2..7c10ca8 100644
--- a/src/features/grid/api/save-draft-changes.ts
+++ b/src/features/grid/api/save-draft-changes.ts
@@ -4,7 +4,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
-import { auth } from "@/lib/auth";
+import { verifierSession } from "@/lib/authz";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
@@ -24,6 +24,9 @@ const SaveDraftsSchema = z.object({
export async function saveDraftChanges(
raw: unknown
): Promise<{ success: boolean; saved: number; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { success: false, saved: 0, error: acces.message };
+
const parsed = SaveDraftsSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
@@ -31,9 +34,8 @@ export async function saveDraftChanges(
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
- const session = await auth();
- const rawUserId = (session?.user as { id?: string | number })?.id;
- const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
+ const rawUserId = acces.utilisateur.id;
+ const userId = rawUserId ? parseInt(rawUserId, 10) : null;
const finalUserId = userId && !isNaN(userId) ? userId : null;
try {
diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx
index be4bfd1..3a02738 100644
--- a/src/features/grid/components/grid-client.tsx
+++ b/src/features/grid/components/grid-client.tsx
@@ -5,7 +5,8 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
-import { useGridStore } from "@/features/grid/store/use-grid-store";
+import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
+import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
import type { ProductRow } from "@/types/grid";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -61,6 +62,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
+ const activeMagasin = useGridStore((s) => s.activeMagasin);
const searchParams = useSearchParams();
const [selectedCodeins, setSelectedCodeins] = useState([]);
@@ -107,18 +109,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const controller = new AbortController();
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
- params.set("magasin", magasin || "TOTAL");
+ // Toujours « tous magasins » : les lignes portent le détail de chaque
+ // magasin, le changement de magasin se fait donc sans rien recharger
+ // (les compléments de l'API FF arrivent à part, cf. useStorePatch).
+ params.set("magasin", "TOTAL");
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
// voyager relançait un chargement complet à chaque changement.
const forceRefresh = refreshRequest !== servedRefreshRef.current;
if (forceRefresh) params.set("refresh", "1");
- const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
+ const cle = rowsKeyFor(codeFournisseur);
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
- // Retour sur la Grille (même fournisseur, même magasin, chargée il y a
- // moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
+ // Retour sur la Grille (même fournisseur, chargée il y a moins de 10 min) :
+ // les lignes sont encore en mémoire, rien à retélécharger.
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
setRowsLoaded(lignesEnMemoire.length);
@@ -127,11 +132,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return;
}
- // Changement de magasin (ou rechargement) sur le même fournisseur : la
- // Grille reste affichée — les lignes portent déjà le détail par magasin,
- // la bascule est immédiate — et les chiffres complétés pour ce magasin
- // remplacent l'affichage d'un bloc, une fois reçus.
- const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
+ // « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la
+ // Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc.
+ const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0;
const accumulatedRows: ProductRow[] = [];
let lastFlush = 0;
@@ -210,7 +213,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
}
flush(loaded, total, true);
- setRowsMeta({ key: cle, loadedAt: Date.now() });
+ setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] });
if (forceRefresh) servedRefreshRef.current = refreshRequest;
} catch (error) {
if (!controller.signal.aborted) {
@@ -226,9 +229,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
- }, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
+ }, [codeFournisseur, refreshRequest, setRows, isMounted]);
- // Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
+ // Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
+ useStorePatch(codeFournisseur, isMounted);
+
+ // Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les
+ // changements suivants passent par le store et ne re-rendent pas la page.
useEffect(() => {
if (!isMounted) return;
setActiveMagasin(magasin || "TOTAL");
@@ -259,7 +266,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga