mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 12:16:56 +02:00
feat(api): API externe de rapprochement BL / factures
Nouvelle API sous /api/ext/v1, authentifiée par clé (EXTERNAL_API_KEYS, en-tête X-API-Key ou Authorization: Bearer), pour qu'un outil tiers lise magasins, fournisseurs et livraisons livrées (n° BL) puis écrive la référence, les montants et l'échéance de la facture, avec validation/dévalidation optionnelle du rapprochement. - normalizeDateString extrait dans server/dateUtils.ts pour être partagé - /api/ext/ exempté du CSRF (pas de cookie de session) - EXTERNAL_API_KEYS ajouté à .env.example et docker-compose.yml - documentation : docs/API-RAPPROCHEMENT.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2274c9bb4a
commit
6f1a39e4b2
7 files changed
+575
-73
No files matched your search
@@ -0,0 +1,73 @@
|
||||
// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés)
|
||||
// vers YYYY-MM-DD. Partagée par les routes web et l'API externe.
|
||||
export function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// API externe de rapprochement BL / factures.
|
||||
//
|
||||
// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins,
|
||||
// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la
|
||||
// référence et le montant de la facture sur une livraison.
|
||||
//
|
||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||
// - variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ; vide => API désactivée ;
|
||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||
//
|
||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||
|
||||
import type { Express, Request, Response, NextFunction } from "express";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./storage";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { invoiceVerificationService } from "./invoiceVerification";
|
||||
|
||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
// Clés relues à chaque requête : une rotation via l'environnement ne demande
|
||||
// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire.
|
||||
function configuredKeyDigests(): Buffer[] {
|
||||
return (process.env.EXTERNAL_API_KEYS || "")
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.map(digest);
|
||||
}
|
||||
|
||||
function extractKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) return header.trim();
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const keys = configuredKeyDigests();
|
||||
if (keys.length === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée (EXTERNAL_API_KEYS non défini)" });
|
||||
}
|
||||
|
||||
const provided = extractKey(req);
|
||||
// Comparaison à temps constant sur les empreintes (longueur fixe)
|
||||
const providedDigest = provided ? digest(provided) : null;
|
||||
const valid = providedDigest !== null && keys.some((k) => timingSafeEqual(k, providedDigest));
|
||||
|
||||
if (!valid) {
|
||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||
const amountSchema = z
|
||||
.union([z.number(), z.string()])
|
||||
.nullable()
|
||||
.transform((value, ctx) => {
|
||||
if (value === null) return null;
|
||||
const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", ".");
|
||||
if (raw === "") return null;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || Math.abs(num) >= 1e8) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return num.toFixed(2); // colonne decimal(10,2)
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z
|
||||
.object({
|
||||
invoiceReference: z.string().trim().max(100).nullable().optional(),
|
||||
invoiceAmount: amountSchema.optional(),
|
||||
invoiceAmountTTC: amountSchema.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
reconciled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" });
|
||||
|
||||
const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const;
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
// Format public d'une livraison : champs utiles au rapprochement uniquement
|
||||
function toApiDelivery(d: any) {
|
||||
return {
|
||||
id: d.id,
|
||||
storeId: d.groupId,
|
||||
storeName: d.group?.name ?? null,
|
||||
supplierId: d.supplierId,
|
||||
supplierName: d.supplier?.name ?? null,
|
||||
supplierCode: d.supplier?.codefou ?? null,
|
||||
status: d.status,
|
||||
scheduledDate: toIsoDate(d.scheduledDate),
|
||||
deliveredDate: toIsoDateTime(d.deliveredDate),
|
||||
blNumber: d.blNumber ?? null,
|
||||
blAmount: toNumber(d.blAmount),
|
||||
invoiceReference: d.invoiceReference ?? null,
|
||||
invoiceAmount: toNumber(d.invoiceAmount),
|
||||
invoiceAmountTTC: toNumber(d.invoiceAmountTTC),
|
||||
dueDate: toIsoDate(d.dueDate),
|
||||
reconciled: !!d.reconciled,
|
||||
validatedAt: toIsoDateTime(d.validatedAt),
|
||||
updatedAt: toIsoDateTime(d.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntParam(value: unknown): number | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) && num > 0 ? num : null; // null = invalide
|
||||
}
|
||||
|
||||
function parseBoolParam(value: unknown): boolean | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateParam(value: unknown): string | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI)
|
||||
async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise<Date | null> {
|
||||
try {
|
||||
const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled);
|
||||
const normalized = result.exists ? normalizeDateString(result.dueDate) : null;
|
||||
return normalized ? new Date(normalized) : null;
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExternalApi(app: Express) {
|
||||
const base = EXTERNAL_API_PREFIX;
|
||||
app.use(base, requireApiKey);
|
||||
|
||||
// Magasins
|
||||
app.get(`${base}/stores`, async (_req, res) => {
|
||||
try {
|
||||
const groups = await storage.getGroups();
|
||||
res.json(groups.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] stores:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des magasins" });
|
||||
}
|
||||
});
|
||||
|
||||
// Fournisseurs
|
||||
app.get(`${base}/suppliers`, async (_req, res) => {
|
||||
try {
|
||||
const suppliers = await storage.getSuppliers();
|
||||
res.json(
|
||||
suppliers.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.codefou ?? null,
|
||||
paymentMethod: s.paymentMethod ?? null,
|
||||
automaticReconciliation: !!s.automaticReconciliation,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] suppliers:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" });
|
||||
}
|
||||
});
|
||||
|
||||
// Livraisons livrées (périmètre du rapprochement BL / factures)
|
||||
app.get(`${base}/deliveries`, async (req, res) => {
|
||||
const storeId = parseIntParam(req.query.storeId);
|
||||
const supplierId = parseIntParam(req.query.supplierId);
|
||||
const reconciled = parseBoolParam(req.query.reconciled);
|
||||
const hasBl = parseBoolParam(req.query.hasBl);
|
||||
const hasInvoice = parseBoolParam(req.query.hasInvoice);
|
||||
const from = parseDateParam(req.query.from);
|
||||
const to = parseDateParam(req.query.to);
|
||||
const limit = parseIntParam(req.query.limit);
|
||||
const offsetRaw = req.query.offset;
|
||||
const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw);
|
||||
const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : "";
|
||||
|
||||
const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit })
|
||||
.filter(([, v]) => v === null)
|
||||
.map(([k]) => k);
|
||||
if (!Number.isInteger(offset) || offset < 0) invalid.push("offset");
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
try {
|
||||
let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" });
|
||||
|
||||
rows = rows.filter((d) => {
|
||||
if (supplierId && d.supplierId !== supplierId) return false;
|
||||
if (reconciled !== undefined && !!d.reconciled !== reconciled) return false;
|
||||
if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false;
|
||||
if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false;
|
||||
if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false;
|
||||
if (from || to) {
|
||||
// Date de livraison effective, sinon date prévue
|
||||
const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate);
|
||||
if (!day) return false;
|
||||
if (from && day < from) return false;
|
||||
if (to && day > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? "";
|
||||
const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? "";
|
||||
return da < db ? 1 : da > db ? -1 : b.id - a.id;
|
||||
});
|
||||
|
||||
const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
res.json({
|
||||
total: rows.length,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
items: rows.slice(offset, offset + pageSize).map(toApiDelivery),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] deliveries:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des livraisons" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
res.json(toApiDelivery(delivery));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Écriture de la facture (référence, montants, échéance) et validation optionnelle
|
||||
app.patch(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
const parsed = updateInvoiceSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Corps de requête invalide",
|
||||
details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
|
||||
if (delivery.status !== "delivered") {
|
||||
return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" });
|
||||
}
|
||||
|
||||
const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined);
|
||||
// Une livraison rapprochée est figée, comme dans le webUI : il faut la
|
||||
// dévalider (reconciled: false) pour modifier sa facture.
|
||||
if (delivery.reconciled && touchesInvoice && body.reconciled !== false) {
|
||||
return res.status(409).json({
|
||||
error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture",
|
||||
});
|
||||
}
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null;
|
||||
if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount;
|
||||
if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC;
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
update.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
} else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) {
|
||||
// Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence
|
||||
update.dueDate = update.invoiceReference
|
||||
? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (body.reconciled !== undefined) {
|
||||
update.reconciled = body.reconciled;
|
||||
update.validatedAt = body.reconciled ? new Date() : null;
|
||||
}
|
||||
|
||||
await storage.updateDelivery(id, update);
|
||||
console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`);
|
||||
|
||||
const updated = await storage.getDelivery(id);
|
||||
res.json(toApiDelivery(updated));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] update delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI
|
||||
app.use(base, (_req, res) => {
|
||||
res.status(404).json({ error: "Route inconnue" });
|
||||
});
|
||||
}
|
||||
+5
-73
@@ -4,6 +4,8 @@ import { storage } from "./storage";
|
||||
import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { registerExternalApi } from "./externalApi";
|
||||
|
||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||
function redactBody(body: any): any {
|
||||
@@ -29,79 +31,6 @@ const require = createRequire(import.meta.url);
|
||||
console.log('🔍 Using development storage and authentication');
|
||||
|
||||
|
||||
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
|
||||
function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
|
||||
// Simple hash password function using crypto
|
||||
async function hashPasswordSimple(password: string) {
|
||||
const crypto = await import('crypto');
|
||||
@@ -165,6 +94,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
console.log('🌍 Environment detected:', environment);
|
||||
|
||||
// API externe de rapprochement (clé d'API, hors session) : docs/API-RAPPROCHEMENT.md
|
||||
registerExternalApi(app);
|
||||
|
||||
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
|
||||
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
|
||||
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
|
||||
|
||||
@@ -50,6 +50,7 @@ export function setupCsrfProtection(app: Express) {
|
||||
const csrfExemptPaths = [
|
||||
'/api/health',
|
||||
'/api/webhook', // External webhook callbacks
|
||||
'/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session
|
||||
];
|
||||
|
||||
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
|
||||
|
||||
Reference in new issue
Block a user