Lot 5 : changement de magasin instantané et contrôle des droits

Grille
- Corrige le lot 4 : au changement de magasin, les chiffres et le tri
  affichés suivent tout de suite (nouvelle liste), la sélection est gardée.
- Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans
  aller-retour serveur (URL mise à jour par l'historique du navigateur).
- Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch),
  appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts).
- La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la
  sélection n'est remise à zéro qu'au changement de fournisseur.

Sécurité
- lib/authz.ts : session relue en base (cache 1 min), rôle de la base
  prioritaire ; variantes pour actions, routes et pages.
- Paramètres réservés aux administrateurs ; session vérifiée dans les actions
  Grille, Commandes, Historique et les routes ; diagnostics réservés admin.
- Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de
  passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement
  de la base n'efface plus les clés d'IA.
- admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé
  uniquement si la base est injoignable ; data/ ignoré par git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-04 06:39:49 +00:00
1 parent b7ee413d67
commit 26b9364232
44 files changed
+1155 -531

No files matched your search

+3
View File
@@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts
.playwright-mcp/
# données locales (configuration avec mots de passe, comptes de secours)
/data/
+33
View File
@@ -376,3 +376,36 @@ Autres changements :
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
**Grille : changement de magasin**
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
- Il n'est demandé qu'une fois par magasin et par chargement.
- **Sélection** : remise à zéro seulement au changement de fournisseur.
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
**Sécurité**
- **Contrôle commun `lib/authz.ts`** :
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
- si la base est injoignable, le jeton de session fait foi.
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
- **Mots de passe et clés d'IA** :
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
- **Compte admin/admin** :
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
- `data/` est ignoré par git.
**À savoir après la mise en ligne**
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifierAdmin } from "@/lib/authz";
import { SynchronisationClient } from "./client";
export const metadata: Metadata = { title: "Synchronisation" };
@@ -6,9 +8,10 @@ export const metadata: Metadata = { title: "Synchronisation" };
/**
* Paramétrage de la synchronisation nocturne des fournisseurs.
*
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
* /api/admin/sync revérifient le rôle côté serveur.
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
*/
export default function SynchronisationPage() {
export default async function SynchronisationPage() {
if (!(await verifierAdmin()).ok) redirect("/dashboard");
return <SynchronisationClient />;
}
+60 -40
View File
@@ -30,7 +30,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st
import {
testDatabaseConnection,
saveDatabaseSettings,
getSavedDatabaseConfig,
getParametresAffichables,
saveQlikSettings,
testQlikConnection,
saveFfApiSettings,
@@ -45,12 +45,13 @@ import { ServerLogs } from "@/features/settings/components/server-logs";
/**
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
* serveur s'exécutent les unes après les autres.
* serveur s'exécutent les unes après les autres. Elle ne contient aucun mot de
* passe : seulement l'indication qu'il y en a un d'enregistré.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
savedConfigPromise = getParametresAffichables().catch((e) => {
savedConfigPromise = null;
throw e;
});
@@ -142,6 +143,13 @@ function ResultatTest({ test }: { test: EtatTest }) {
return null;
}
/** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */
function placeholderMotDePasse(enregistre: boolean): string {
return enregistre ? "•••••• (inchangé)" : "••••••••";
}
const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré.";
// ── PostgreSQL ───────────────────────────────────────────────────────────────
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
@@ -152,9 +160,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl,
motDePasseEnregistre, setMotDePasseEnregistre,
} = useDbSettingsStore();
// Mot de passe vide : le serveur reprend celui qui est enregistré.
const champs = () => ({ host, port, database, user, password: password || undefined, ssl });
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
const [enregistrement, setEnregistrement] = useState(false);
const [rechargement, setRechargement] = useState(false);
@@ -162,7 +173,7 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const tester = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testDatabaseConnection(getDatabaseUrl());
const res = await testDatabaseConnection(champs());
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
@@ -172,9 +183,14 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const enregistrer = async () => {
setEnregistrement(true);
try {
const res = await saveDatabaseSettings(getDatabaseUrl());
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
const res = await saveDatabaseSettings(champs());
if (res.success) {
toast.succes("Connexion à la base PostgreSQL enregistrée.");
if (password) {
setPassword("");
setMotDePasseEnregistre(true);
}
} else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
@@ -223,8 +239,8 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
</Champ>
<Champ id="pg-mdp" label="Mot de passe">
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Champ>
</div>
@@ -283,8 +299,8 @@ function SectionFfApi() {
useEffect(() => {
loadSavedConfig()
.then((cfg) => {
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
setSavedUrl(cfg.ffApiBaseUrl || null);
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
})
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
}, []);
@@ -418,7 +434,9 @@ function SectionFfApi() {
function SectionQlik() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
// Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur.
const [password, setPassword] = useState("");
const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false);
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
@@ -426,10 +444,9 @@ function SectionQlik() {
useEffect(() => {
loadSavedConfig()
.then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
setHost(c.qlik.host);
setUser(c.qlik.user);
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
})
.catch(() => { /* l'échec de lecture est signalé par la page */ });
}, []);
@@ -437,9 +454,14 @@ function SectionQlik() {
const handleSave = async () => {
setSaving(true);
try {
const res = await saveQlikSettings(host.trim(), user.trim(), password);
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
const res = await saveQlikSettings(host.trim(), user.trim(), password || undefined);
if (res.success) {
toast.succes("Identifiants Qlik enregistrés.");
if (password) {
setPassword("");
setMotDePasseEnregistre(true);
}
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
@@ -450,14 +472,15 @@ function SectionQlik() {
const handleTest = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testQlikConnection(host.trim(), user.trim(), password);
const res = await testQlikConnection(host.trim(), user.trim(), password || undefined);
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
}
};
const testImpossible = !user || !password;
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
const testImpossible = !user || (!password && !motDePasseEnregistre);
return (
<Card>
@@ -488,9 +511,9 @@ function SectionQlik() {
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
</Champ>
<Champ id="qlik-mdp" label="Mot de passe">
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
<div className="relative">
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
@@ -542,24 +565,21 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
const setUser = useDbSettingsStore((s) => 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
+5
View File
@@ -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 <ParametresClient onglet={onglet} />;
}
+5 -14
View File
@@ -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<NextResponse | null> {
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<void> {
try {
const fournisseurs = await pgGetFournisseurs();
@@ -102,8 +93,8 @@ async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
/** 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) });
+5 -14
View File
@@ -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<NextResponse | null> {
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<NextResponse | null> {
* 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 {
+7 -16
View File
@@ -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<NextResponse | null> {
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 {
+4
View File
@@ -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();
+9 -5
View File
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown> | undefined;
@@ -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) {
+4
View File
@@ -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) {
@@ -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) {
+6 -2
View File
@@ -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 },
+4
View File
@@ -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<Uint8Array>, 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");
@@ -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 });
}
}
+3 -12
View File
@@ -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<NextResponse | null> {
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) {
+3 -7
View File
@@ -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);
+7 -12
View File
@@ -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<NextResponse | null> {
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 });
+9 -37
View File
@@ -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<NextResponse | null> {
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<NextResponse | null> {
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) {
+8 -2
View File
@@ -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 });
}
}
+6 -14
View File
@@ -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<ApiKeyRow[]> {
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");
+8 -20
View File
@@ -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." };
}
}
+63 -41
View File
@@ -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<boolean> | 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<boolean> {
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<boolean> {
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<number>`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;
}
+14 -2
View File
@@ -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<CadenceView[]> {
await requireSession();
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map<string, string>();
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)
+5
View File
@@ -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<GridFilters>
): Promise<ProductRow[]> {
await requireSession();
return getProductRows({ codeFournisseur, magasin, filters });
}
+83 -94
View File
@@ -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<ProductRow[
}
}
async function reconcileSelectedStoreFromMensuelApi(
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
function plafondComplement(): number {
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
}
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
async function fetchStorePatchEntries(
rows: ProductRow[],
magasin: string,
dateDebut: string,
dateFin: string,
sortedPeriods: string[]
) {
if (magasin === "TOTAL") return;
const candidates = rows.filter((row) => {
if (!row.noid) return false;
const storeQty = row.quantiteByStore?.[magasin] ?? 0;
return storeQty === 0;
});
if (candidates.length === 0) return;
periods: string[],
): Promise<StorePatchEntry[]> {
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<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
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<ProductRow[], Map<string, Promise<StorePatch>>>();
/**
* 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<StorePatch> {
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.
*
+6 -4
View File
@@ -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 {
+22 -14
View File
@@ -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<string[]>([]);
@@ -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
</h1>
</div>
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
<MetricPill label="Magasin" value={nomMagasin(activeMagasin)} />
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
</div>
</div>
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
{/* Main content */}
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
codeFournisseur={codeFournisseur}
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
nomFournisseur={nomFournisseur}
@@ -97,11 +97,15 @@ function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const activeMagasin = useGridStore((s) => s.activeMagasin);
// Changement de magasin sans aller-retour serveur : les lignes portent déjà
// le détail par magasin. L'URL suit (lien partageable, retour par le menu),
// via l'historique du navigateur, que Next répercute sur useSearchParams.
const handleStoreSelect = (code: string) => {
if (code === activeMagasin) return;
setActiveMagasin(code);
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.replace(`/grid?${params.toString()}`, { scroll: false });
window.history.replaceState(null, "", `?${params.toString()}`);
};
+21 -9
View File
@@ -52,6 +52,8 @@ import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
interface HeatmapGridProps {
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
codeFournisseur: string;
onSelectionChange?: (codeins: string[]) => void;
isAdmin?: boolean;
/** Sert à nommer et documenter les extractions issues de la Grille. */
@@ -699,10 +701,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
// =========================================================================
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const code3 = useGridStore((s) => s.filters.code3);
const codeGamme = useGridStore((s) => s.filters.codeGamme);
const search = useGridStore((s) => s.filters.search);
const displayDensity = useGridStore((s) => s.displayDensity);
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
const activeMagasin = useGridStore((s) => s.activeMagasin);
@@ -713,12 +719,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
const { code3, codeGamme } = filters;
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
void activeMagasin;
// Garde : un état persisté d'une version antérieure peut encore contenir
// une chaîne. La migration du store le corrige, mais un `new Set("320211")`
// produirait un ensemble de caractères et viderait la Grille sans rien dire.
const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null;
if (!codes && !codeGamme) return rows;
if (!codes && !codeGamme) return rows.slice();
// Set plutôt que includes() : la liste peut compter des dizaines de
// nomenclatures, et le filtre est réévalué pour chaque ligne.
const nomenclatures = codes ? new Set(codes) : null;
@@ -731,7 +741,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
}
return true;
});
}, [rows, filters, draftChanges]);
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -775,10 +785,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
[]
);
// Reset selection when rows change (e.g. supplier change)
// Sélection remise à zéro au changement de fournisseur seulement : elle est
// indexée par code article, et survit donc à un rechargement des lignes, à un
// enregistrement ou au complément des chiffres d'un magasin.
useEffect(() => {
setRowSelection({});
}, [rows]);
}, [codeFournisseur]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
@@ -1198,7 +1210,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
const table = useReactTable({
data: filteredData,
columns,
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
@@ -1249,7 +1261,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
}, [table, rowSelection, onSelectionChange, filteredData, search]);
const rowVirtualizer = useVirtualizer({
@@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
import type { StorePatch } from "@/features/grid/lib/store-patch";
/**
* Demande le complément de l'API FF du magasin affiché, une fois les lignes
* chargées, et l'applique à son arrivée. Une seule demande par magasin et par
* chargement : revenir sur un magasin déjà complété ne coûte rien.
*/
export function useStorePatch(codeFournisseur: string, enabled: boolean) {
const activeMagasin = useGridStore((s) => s.activeMagasin);
const rowsMeta = useGridStore((s) => s.rowsMeta);
const applyStorePatch = useGridStore((s) => s.applyStorePatch);
useEffect(() => {
if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return;
if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return;
const controller = new AbortController();
const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt };
const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin });
fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" })
.then((res) => (res.ok ? (res.json() as Promise<StorePatch>) : null))
.then((patch) => {
if (patch && !controller.signal.aborted) applyStorePatch(gen, patch);
})
// Échec ou abandon : les chiffres SQL restent affichés, et la demande
// repartira au prochain passage sur ce magasin.
.catch(() => {});
return () => controller.abort();
}, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Complément des chiffres d'un magasin par l'API FF (ventes mensuelles).
*
* Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
* articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
* pour ces articles seulement, les séries et totaux du magasin.
*
* Module sans dépendance d'exécution : utilisé côté serveur (calcul du
* complément) comme côté navigateur (application aux lignes affichées).
*/
import type { ProductRow } from "@/types/grid";
import type { FfMensuelEntry } from "@/lib/api-ff-client";
/** Chiffres d'un article pour le magasin complété. */
export interface StorePatchEntry {
codein: string;
/** Ventes par mois (« YYYYMM »). */
sales: Record<string, number>;
/** Stock de fin de mois, reporté sur les mois sans donnée. */
stock: Record<string, number>;
receptions: Record<string, number>;
quantite: number;
ca: number;
marge: number;
}
export interface StorePatch {
magasin: string;
/** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */
periods: string[];
entries: StorePatchEntry[];
}
/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */
export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] {
return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0);
}
/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */
export function buildStorePatchEntries(
retenus: ProductRow[],
magasin: string,
periods: string[],
mensuel: Map<string, FfMensuelEntry[]>,
): StorePatchEntry[] {
const resultat: StorePatchEntry[] = [];
for (const row of retenus) {
const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!periods.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;
const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 };
let lastStock = 0;
for (const period of periods) {
const value = byPeriod.get(period);
e.sales[period] = value?.qty ?? 0;
e.receptions[period] = value?.receptions ?? 0;
if (value) lastStock = value.stock;
e.stock[period] = lastStock;
e.quantite += value?.qty ?? 0;
e.ca += value?.ca ?? 0;
e.marge += value?.marge ?? 0;
}
resultat.push(e);
}
return resultat;
}
/**
* Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
* magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
* Renvoie le nombre de lignes corrigées.
*/
export function applyStorePatchInPlace(
rows: ProductRow[],
magasin: string,
periods: string[],
entries: StorePatchEntry[],
): number {
const parCode = new Map(entries.map((e) => [e.codein, e]));
let corrigees = 0;
for (const row of rows) {
const e = parCode.get(row.codein);
if (!e) continue;
row.sales12mByStore ??= {};
row.stock12mByStore ??= {};
row.receptions12mByStore ??= {};
row.caByStore ??= {};
row.quantiteByStore ??= {};
row.margeByStore ??= {};
row.sales12mByStore[magasin] ??= {};
row.stock12mByStore[magasin] ??= {};
row.receptions12mByStore[magasin] ??= {};
for (const period of periods) {
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
const nextQty = e.sales[period] ?? 0;
row.sales12mByStore[magasin][period] = nextQty;
row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0;
row.stock12mByStore[magasin][period] = e.stock[period] ?? 0;
row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty);
}
const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0);
const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0);
const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0);
row.quantiteByStore[magasin] = e.quantite;
row.caByStore[magasin] = e.ca;
row.margeByStore[magasin] = e.marge;
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);
corrigees++;
}
return corrigees;
}
/**
* Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls.
* Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2
* magasins » reste identique quel que soit le magasin consulté avant.
*/
export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow {
return {
...row,
sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales },
stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock },
receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions },
quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite },
caByStore: { ...row.caByStore, [magasin]: e.ca },
margeByStore: { ...row.margeByStore, [magasin]: e.marge },
workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin],
};
}
+51 -5
View File
@@ -3,6 +3,19 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch";
import { getMonthsFromRows } from "@/features/grid/lib/months";
/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */
export interface RowsMeta {
key: string;
loadedAt: number;
/** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */
patchedStores: string[];
}
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
interface GridState {
/** Source data from server */
@@ -25,12 +38,17 @@ interface GridState {
refreshRequest: number;
requestRefresh: () => void;
/**
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
* revient, et à changer de magasin sans vider l'écran.
* Ce que contiennent `rows` et quand elles ont été chargées. Non persisté :
* sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels
* magasins ont déjà reçu leur complément.
*/
rowsMeta: { key: string; loadedAt: number } | null;
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
rowsMeta: RowsMeta | null;
setRowsMeta: (meta: RowsMeta | null) => void;
/**
* Applique le complément d'un magasin, s'il concerne encore les lignes
* affichées (`gen` = clé et date de chargement au moment de la demande).
*/
applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void;
/** Persisted column visibility state */
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
@@ -231,6 +249,34 @@ export const useGridStore = create<GridState>()(
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
setRowsMeta: (meta) => set({ rowsMeta: meta }),
applyStorePatch: (gen, patch) => {
const { rows, rowsByCodein, rowsMeta } = get();
if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return;
const patchedStores = rowsMeta.patchedStores.includes(patch.magasin)
? rowsMeta.patchedStores
: [...rowsMeta.patchedStores, patch.magasin];
// Complément calculé sur une autre fenêtre de 12 mois (changement de
// mois entre les deux requêtes) : on le laisse de côté.
const mois = getMonthsFromRows(rows);
const memeFenetre = !mois || mois.join(",") === patch.periods.join(",");
const parCode = new Map(patch.entries.map((e) => [e.codein, e]));
if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) {
set({ rowsMeta: { ...rowsMeta, patchedStores } });
return;
}
// Nouvelles lignes pour les seuls articles complétés : les autres
// gardent leur objet, et donc leur rendu mémorisé.
const nouvelles = rows.map((r) => {
const e = parCode.get(r.codein);
return e ? withStorePatch(r, patch.magasin, e) : r;
});
set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } });
if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
},
restoreSnapshot: (changes) => {
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
},
+129 -123
View File
@@ -1,43 +1,114 @@
"use server";
/**
* Actions des Paramètres — réservées aux administrateurs.
*
* Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et
* les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide
* dans le formulaire signifie « conserver celui qui est enregistré ».
*/
import { Pool } from "pg";
import fs from "fs/promises";
import path from "path";
import { requireAdmin, verifierAdmin } from "@/lib/authz";
import { CONFIG_FILE, readConfig, writeConfig } from "./config-file";
import { diagnostiquerApiFf } from "./ff-api-diagnostic";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface DbConfig {
url: string;
openRouterKey?: string;
openRouterModel?: string;
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
/**
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
* sans redéployer.
*/
ffApiBaseUrl?: string;
export interface ChampsBaseDeDonnees {
host: string;
port: string;
database: string;
user: string;
/** Vide ou absent : le mot de passe enregistré est conservé. */
password?: string;
ssl: boolean;
}
/** Lit la config existante (ou {} si absente). */
async function readConfig(): Promise<Partial<DbConfig>> {
/** Ce que le formulaire peut afficher : jamais de mot de passe ni de clé. */
export interface ParametresAffichables {
db: {
host: string;
port: string;
database: string;
user: string;
ssl: boolean;
motDePasseEnregistre: boolean;
} | null;
qlik: { host: string; user: string; motDePasseEnregistre: boolean };
ffApiBaseUrl: string;
}
/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */
function decoder(valeur: string): string {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
return decodeURIComponent(valeur);
} catch {
return {};
return valeur;
}
}
export async function testDatabaseConnection(url: string) {
/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */
function lireUrlPostgres(url: string) {
try {
const u = new URL(url);
return {
host: u.hostname,
port: u.port || "5432",
database: decoder(u.pathname.slice(1)),
user: decoder(u.username),
password: decoder(u.password),
ssl: url.includes("sslmode=require"),
};
} catch (e) {
console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message);
return null;
}
}
function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string {
const user = encodeURIComponent(champs.user.trim());
const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user;
const port = champs.port.trim() || "5432";
return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`;
}
/** Mot de passe saisi, sinon celui de l'URL enregistrée. */
async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise<string> {
if (champs.password) return champs.password;
const { url } = await readConfig();
return (url && lireUrlPostgres(url)?.password) || "";
}
/** Réglages enregistrés, sans aucun secret. */
export async function getParametresAffichables(): Promise<ParametresAffichables> {
await requireAdmin();
const config = await readConfig();
const base = config.url ? lireUrlPostgres(config.url) : null;
return {
db: base
? {
host: base.host,
port: base.port,
database: base.database,
user: base.user,
ssl: base.ssl,
motDePasseEnregistre: base.password !== "",
}
: null,
qlik: {
host: config.qlikHost ?? "",
user: config.qlikUser ?? "",
// Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement.
motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD),
},
ffApiBaseUrl: config.ffApiBaseUrl ?? "",
};
}
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
const pool = new Pool({
connectionString: url,
@@ -58,24 +129,15 @@ export async function testDatabaseConnection(url: string) {
}
}
export async function saveDatabaseSettings(
url: string,
openRouterKey?: string,
openRouterModel?: string,
aiProvider?: "openrouter" | "google",
googleAiKey?: string,
googleAiModel?: string,
) {
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
const existing = await readConfig();
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// S'assurer que le dossier data existe
await fs.mkdir(DATA_DIR, { recursive: true });
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
console.log(`[Settings] Saving config to ${CONFIG_FILE}`);
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ url });
console.log("[Settings] Database configuration saved successfully.");
// Refresh the shared DB instance immediately
@@ -90,12 +152,13 @@ export async function saveDatabaseSettings(
}
}
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const existing = await readConfig();
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) });
console.log("[Settings] Qlik configuration saved.");
return { success: true };
} catch (error: unknown) {
@@ -105,10 +168,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP
}
}
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
const enregistre = getQlikConfig();
const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password };
if (qlikHost) cfg.host = qlikHost;
await qlikNtlmSession(cfg);
return { success: true };
@@ -120,15 +188,15 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
export async function saveFfApiSettings(ffApiBaseUrl: string) {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, error: acces.message };
try {
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
}
const existing = await readConfig();
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await writeConfig({ ffApiBaseUrl: cleaned || undefined });
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
resetFfApiBaseCache();
@@ -141,71 +209,9 @@ export async function saveFfApiSettings(ffApiBaseUrl: string) {
}
}
/**
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
*
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
*/
export async function testFfApiConnection(ffApiBaseUrl?: string) {
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
const url = `${base}/api/sync/status`;
const started = Date.now();
try {
const res = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
const ms = Date.now() - started;
if (!res.ok) {
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
}
const body = await res.json().catch(() => null);
if (!body) {
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
}
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
const status = normalizeSyncStatus(body);
if (!status) {
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
}
return { success: true, url, ms, status };
} catch (error: unknown) {
const ms = Date.now() - started;
const raw = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
let hint = raw;
if (name === "TimeoutError" || name === "AbortError") {
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
} else if (cause?.code === "ENOTFOUND") {
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
} else if (cause?.code === "ECONNREFUSED") {
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
} else if (cause?.code) {
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
}
console.error(`[Settings] FF API test KO (${url}):`, raw);
return { success: false, url, error: hint };
}
}
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
try {
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
return null;
}
const data = await fs.readFile(CONFIG_FILE, "utf-8");
const config = JSON.parse(data);
console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`);
return config;
} catch (error) {
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
return null;
}
/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<Awaited<ReturnType<typeof diagnostiquerApiFf>>> {
const acces = await verifierAdmin();
if (!acces.ok) return { success: false, url: "", error: acces.message };
return diagnostiquerApiFf(ffApiBaseUrl);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* CollectFlow — Fichier de configuration `data/.db-config.json`.
*
* Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être
* appelables depuis le navigateur (le fichier contient des mots de passe et des
* clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
export const DATA_DIR = path.join(process.cwd(), "data");
export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface DbConfig {
url: string;
openRouterKey?: string;
openRouterModel?: string;
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
/**
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
* sans redéployer.
*/
ffApiBaseUrl?: string;
}
/** Lit la config existante (ou {} si absente). */
export async function readConfig(): Promise<Partial<DbConfig>> {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
/**
* Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
* conservées telles quelles (une clé passée à `undefined` est retirée).
*/
export async function writeConfig(patch: Partial<DbConfig>): Promise<void> {
const existing = await readConfig();
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2));
}
@@ -0,0 +1,64 @@
/**
* CollectFlow — Diagnostic de l'API FF Nancy.
*
* Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
* route `/api/ff-status` (tout utilisateur connecté). Module serveur et non
* fichier d'actions : chaque appelant fait son propre contrôle d'accès.
*/
import "server-only";
import { readConfig } from "./config-file";
/**
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
*
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
*/
export async function diagnostiquerApiFf(ffApiBaseUrl?: string) {
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
const url = `${base}/api/sync/status`;
const started = Date.now();
try {
const res = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
const ms = Date.now() - started;
if (!res.ok) {
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
}
const body = await res.json().catch(() => null);
if (!body) {
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
}
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
const status = normalizeSyncStatus(body);
if (!status) {
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
}
return { success: true, url, ms, status };
} catch (error: unknown) {
const ms = Date.now() - started;
const raw = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
let hint = raw;
if (name === "TimeoutError" || name === "AbortError") {
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
} else if (cause?.code === "ENOTFOUND") {
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
} else if (cause?.code === "ECONNREFUSED") {
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
} else if (cause?.code) {
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
}
console.error(`[Settings] FF API test KO (${url}):`, raw);
return { success: false, url, error: hint };
}
}
@@ -8,29 +8,33 @@ export interface DatabaseSettings {
port: string;
database: string;
user: string;
/** Saisie en cours uniquement : jamais enregistré dans le navigateur. */
password?: string;
ssl: boolean;
}
interface DatabaseSettingsState extends DatabaseSettings {
/** Un mot de passe est enregistré sur le serveur (lu au chargement de la page, jamais mémorisé). */
motDePasseEnregistre: boolean;
setHost: (host: string) => void;
setPort: (port: string) => void;
setDatabase: (database: string) => void;
setUser: (user: string) => void;
setPassword: (password: string) => void;
setSsl: (ssl: boolean) => void;
getDatabaseUrl: () => string;
setMotDePasseEnregistre: (motDePasseEnregistre: boolean) => void;
}
export const useDbSettingsStore = create<DatabaseSettingsState>()(
persist(
(set, get) => ({
(set) => ({
host: "localhost",
port: "5432",
database: "collectflow",
user: "postgres",
password: "",
ssl: false,
motDePasseEnregistre: false,
setHost: (host) => set({ host }),
setPort: (port) => set({ port }),
@@ -38,16 +42,19 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
setUser: (user) => set({ user }),
setPassword: (password) => set({ password }),
setSsl: (ssl) => set({ ssl }),
getDatabaseUrl: () => {
const { user, password, host, port, database, ssl } = get();
const auth = password ? `${user}:${password}` : user;
const url = `postgres://${auth}@${host}:${port}/${database}${ssl ? "?sslmode=require" : ""}`;
return url;
},
setMotDePasseEnregistre: (motDePasseEnregistre) => set({ motDePasseEnregistre }),
}),
{
name: "collectflow-db-settings",
// Le mot de passe reste sur le serveur : seuls les autres champs sont mémorisés.
partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }),
// Version 1 : les versions précédentes enregistraient le mot de passe en clair.
version: 1,
migrate: (persisted) => {
const reste = { ...(persisted as Partial<DatabaseSettings> | undefined) };
delete reste.password;
return reste as DatabaseSettingsState;
},
}
)
);
@@ -3,12 +3,14 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { and, eq, isNull } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { verifierSession } from "@/lib/authz";
export async function deleteSnapshot(id: number) {
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
@@ -29,8 +31,9 @@ export async function deleteSnapshot(id: number) {
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
return { success: true };
} catch (err: any) {
} catch (err) {
// Le détail technique reste dans les journaux du serveur.
console.error("[deleteSnapshot] DB Error:", err);
return { success: false, error: err.message || "Delete failed" };
return { success: false, error: "erreur technique sur le serveur" };
}
}
+6 -6
View File
@@ -2,14 +2,14 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { auth } from "@/lib/auth";
import { requireSession } from "@/lib/authz";
import { and, desc, eq, isNull } from "drizzle-orm";
export async function getSnapshots(type?: "snapshot" | "export") {
const utilisateur = await requireSession();
try {
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const rawUserId = utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
@@ -24,8 +24,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
.from(sessionSnapshots)
.where(and(...conditions))
.orderBy(desc(sessionSnapshots.createdAt));
} catch (err: any) {
const msg = (err?.message || String(err)).split("\n")[0];
} catch (err) {
const msg = ((err as Error)?.message || String(err)).split("\n")[0];
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
+10 -12
View File
@@ -4,7 +4,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
const SaveSnapshotSchema = z.object({
@@ -26,6 +26,9 @@ const SaveSnapshotSchema = z.object({
});
export async function saveSnapshot(raw: unknown) {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = SaveSnapshotSchema.safeParse(raw);
if (!parsed.success) {
console.error("Snapshot validation failed:", parsed.error.format());
@@ -33,9 +36,8 @@ export async function saveSnapshot(raw: unknown) {
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const session = await auth();
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
@@ -59,7 +61,7 @@ export async function saveSnapshot(raw: unknown) {
revalidatePath("/snapshots");
revalidatePath("/exports");
return { success: true, snapshotId: created?.id };
} catch (err: any) {
} catch (err) {
console.error("Initial snapshot save failed, attempting auto-repair...", err);
try {
@@ -103,14 +105,10 @@ export async function saveSnapshot(raw: unknown) {
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: retryCreated?.id };
} catch (repairErr: any) {
} catch (repairErr) {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
console.error("Snapshot auto-repair/retry failed:", repairErr);
const origMsg = (err.message || String(err)).split('\n')[0];
const retryMsg = (repairErr.message || String(repairErr)).split('\n')[0];
return {
success: false,
error: `Erreur initiale: ${origMsg} | Erreur après réparation: ${retryMsg}`
};
return { success: false, error: "erreur technique sur le serveur" };
}
}
}
@@ -117,7 +117,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
setSnapshots((prev) => prev.filter((x) => x.id !== s.id));
toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique.");
} else {
toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`);
toast.erreur(`La suppression a échoué : ${(res.error ?? "erreur inconnue").replace(/\.$/, "")}.`);
}
} catch (err) {
if (isStaleServerActionError(err)) void proposerRechargement();
+4 -6
View File
@@ -24,7 +24,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
try {
console.log(`[AUTH] Checking user "${credentials.username}"...`);
let user: any = null;
let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined;
// Try DB first
try {
@@ -33,12 +33,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
.where(eq(users.username, credentials.username as string));
user = dbUser;
console.log(`[AUTH] DB check: User found: ${!!user}`);
} catch (dbErr) {
} catch {
// Base injoignable uniquement : un compte absent de la base ne doit
// pas pouvoir se connecter avec le fichier de secours (admin/admin).
console.warn("[AUTH] DB unreachable, falling back to JSON.");
}
// Fallback to JSON if DB failed or user not found
if (!user) {
const fallbackUsers = getFallbackUsers();
user = fallbackUsers.find(u => u.username === credentials.username);
console.log(`[AUTH] JSON check: User found: ${!!user}`);
+164
View File
@@ -0,0 +1,164 @@
/**
* CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages.
*
* Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son
* expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
* relit donc l'utilisateur dans la table `users` (avec un cache court), et le
* rôle enregistré en base l'emporte sur celui du jeton.
*
* Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte
* de secours `data/users.json` ne pourrait plus rien faire — y compris configurer
* la base.
*/
import "server-only";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
export interface UtilisateurCourant {
id: string;
username: string;
role: string;
}
const MESSAGE_SESSION = "Session expirée : reconnectez-vous.";
const MESSAGE_ADMIN = "Action réservée aux administrateurs.";
/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */
export class AccesRefuse extends Error {
constructor(message: string, readonly statut: 401 | 403 = 403) {
super(message);
this.name = "AccesRefuse";
}
}
// ── Relecture du compte en base ──────────────────────────────────────────────
/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */
const CACHE_TTL_MS = 60_000;
/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */
const PAUSE_BASE_INJOIGNABLE_MS = 15_000;
interface FicheUtilisateur {
exists: boolean;
role: string;
username: string;
at: number;
}
const cache = new Map<string, FicheUtilisateur>();
let baseInjoignableDepuis = 0;
/** Plus grand entier accepté par la colonne `users.id` (serial). */
const ID_MAX = 2_147_483_647;
/**
* Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre.
* Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non
* numérique) est cherché tel quel : introuvable si la base répond.
*/
async function lireFiche(id: string): Promise<FicheUtilisateur | null> {
const maintenant = Date.now();
const enCache = cache.get(id);
if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache;
if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null;
const n = /^\d+$/.test(id) ? Number(id) : -1;
const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1;
try {
const [ligne] = await db
.select({ username: users.username, role: users.role })
.from(users)
.where(eq(users.id, idNumerique))
.limit(1);
const fiche: FicheUtilisateur = ligne
? { exists: true, role: ligne.role, username: ligne.username, at: maintenant }
: { exists: false, role: "", username: "", at: maintenant };
cache.set(id, fiche);
return fiche;
} catch (e) {
baseInjoignableDepuis = maintenant;
console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200));
return null;
}
}
// ── Contrôles ────────────────────────────────────────────────────────────────
/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */
export async function requireSession(): Promise<UtilisateurCourant> {
const session = await auth();
const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined;
if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401);
const id = jeton.id != null ? String(jeton.id) : "";
const fiche = await lireFiche(id);
if (!fiche) {
// Mode dégradé : le jeton fait foi.
return { id, username: jeton.name ?? "", role: jeton.role ?? "user" };
}
if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401);
return { id, username: fiche.username, role: fiche.role };
}
/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */
export async function requireAdmin(): Promise<UtilisateurCourant> {
const utilisateur = await requireSession();
if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403);
return utilisateur;
}
// ── Variantes sans exception ─────────────────────────────────────────────────
export type Autorisation =
| { ok: true; utilisateur: UtilisateurCourant }
| { ok: false; message: string; statut: 401 | 403 };
async function verifier(controle: () => Promise<UtilisateurCourant>): Promise<Autorisation> {
try {
return { ok: true, utilisateur: await controle() };
} catch (e) {
if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut };
throw e;
}
}
/**
* Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt
* que de lever une exception, et pour les pages (le `redirect()` doit être appelé
* hors d'un try/catch).
*/
export function verifierSession(): Promise<Autorisation> {
return verifier(requireSession);
}
export function verifierAdmin(): Promise<Autorisation> {
return verifier(requireAdmin);
}
// ── Variantes pour les routes ────────────────────────────────────────────────
function reponse(a: Autorisation): UtilisateurCourant | Response {
return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut });
}
/**
* Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle :
*
* const acces = await sessionOuReponse();
* if (acces instanceof Response) return acces;
*/
export async function sessionOuReponse(): Promise<UtilisateurCourant | Response> {
return reponse(await verifierSession());
}
/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */
export async function adminOuReponse(): Promise<UtilisateurCourant | Response> {
return reponse(await verifierAdmin());
}