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:
MichaelandClaude Opus 5.5 committed 2026-10-09 15:14:45 +02:00
1 parent 2274c9bb4a
commit 6f1a39e4b2
7 files changed
+575 -73

No files matched your search

+5
View File
@@ -30,3 +30,8 @@ ENCRYPTION_KEY=
# Route de secours POST /api/emergency-admin-reset : désactivée si vide. # Route de secours POST /api/emergency-admin-reset : désactivée si vide.
# Ne définir qu'en cas de besoin, puis retirer. # Ne définir qu'en cas de besoin, puis retirer.
EMERGENCY_SECRET= EMERGENCY_SECRET=
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée.
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
EXTERNAL_API_KEYS=
+2
View File
@@ -17,6 +17,8 @@ services:
POSTGRES_PASSWORD: LogiFlow2025! POSTGRES_PASSWORD: LogiFlow2025!
POSTGRES_DB: logiflow_db POSTGRES_DB: logiflow_db
BACKUP_DIR: /app/backups BACKUP_DIR: /app/backups
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
ports: ports:
- "3000:3000" - "3000:3000"
networks: networks:
+141
View File
@@ -0,0 +1,141 @@
# API externe — Rapprochement BL / factures
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
la référence et le montant de la facture, comme dans la page **Rapprochement**.
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
## Activation et authentification
Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
puis redémarrer :
```
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
```
Générer une clé :
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Variable vide ou absente : l'API répond `503` (désactivée).
Chaque requête envoie la clé dans l'un de ces en-têtes :
```
X-API-Key: <clé>
Authorization: Bearer <clé>
```
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
## Points d'accès
### `GET /stores` — magasins
```json
[{ "id": 1, "name": "Magasin Nancy" }]
```
### `GET /suppliers` — fournisseurs
```json
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
```
`code` = code fournisseur (`codefou`).
### `GET /deliveries` — livraisons à rapprocher
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
Rapprochement), de la plus récente à la plus ancienne.
| Paramètre | Exemple | Effet |
|--------------|---------------|------------------------------------------------------------|
| `storeId` | `1` | Un seul magasin |
| `supplierId` | `4` | Un seul fournisseur |
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
| `hasBl` | `true` | Avec / sans numéro de BL |
| `hasInvoice` | `false` | Avec / sans référence facture |
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
| `offset` | `0` | Décalage de pagination |
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
```json
{
"total": 1,
"limit": 100,
"offset": 0,
"items": [
{
"id": 812,
"storeId": 1,
"storeName": "Magasin Nancy",
"supplierId": 4,
"supplierName": "Lactalis",
"supplierCode": "LAC01",
"status": "delivered",
"scheduledDate": "2026-10-08",
"deliveredDate": "2026-10-08T07:42:00.000Z",
"blNumber": "BL-4567",
"blAmount": 1234.5,
"invoiceReference": null,
"invoiceAmount": null,
"invoiceAmountTTC": null,
"dueDate": null,
"reconciled": false,
"validatedAt": null,
"updatedAt": "2026-10-08T07:42:00.000Z"
}
]
}
```
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
### `GET /deliveries/:id` — une livraison
Même objet qu'un élément de `items`. Inconnue : `404`.
### `PATCH /deliveries/:id` — écrire la facture
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
| Champ | Type | Effet |
|--------------------|-------------------------|------------------------------------------------------------------|
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
```bash
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
-H "X-API-Key: $LOGIFLOW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
```
Réponse : la livraison mise à jour.
Règles :
- livraison non livrée : `409` ;
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
- corps invalide : `400` avec le détail par champ.
## Exemple : rapprocher une facture reçue
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
(ajouter `"reconciled": true` pour valider directement).
+73
View File
@@ -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
}
}
+348
View File
@@ -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
View File
@@ -4,6 +4,8 @@ import { storage } from "./storage";
import { setupLocalAuth, requireAuth } from "./localAuth"; import { setupLocalAuth, requireAuth } from "./localAuth";
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions"; import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
import { stripSmtpPassword } from "./sanitize"; import { stripSmtpPassword } from "./sanitize";
import { normalizeDateString } from "./dateUtils";
import { registerExternalApi } from "./externalApi";
// Corps de requête sans les champs secrets : pour les logs uniquement // Corps de requête sans les champs secrets : pour les logs uniquement
function redactBody(body: any): any { function redactBody(body: any): any {
@@ -29,79 +31,6 @@ const require = createRequire(import.meta.url);
console.log('🔍 Using development storage and authentication'); 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 // Simple hash password function using crypto
async function hashPasswordSimple(password: string) { async function hashPasswordSimple(password: string) {
const crypto = await import('crypto'); const crypto = await import('crypto');
@@ -165,6 +94,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
const environment = process.env.NODE_ENV || 'development'; const environment = process.env.NODE_ENV || 'development';
console.log('🌍 Environment detected:', environment); 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. // Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
// Les objets "group" sont joints à de nombreuses réponses (livraisons, // Les objets "group" sont joints à de nombreuses réponses (livraisons,
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on // commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
+1
View File
@@ -50,6 +50,7 @@ export function setupCsrfProtection(app: Express) {
const csrfExemptPaths = [ const csrfExemptPaths = [
'/api/health', '/api/health',
'/api/webhook', // External webhook callbacks '/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))) { if (csrfExemptPaths.some(path => req.path.startsWith(path))) {