From 6f1a39e4b2894930c5d771645f7014a99c011122 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 9 Oct 2026 15:14:45 +0200 Subject: [PATCH] feat(api): API externe de rapprochement BL / factures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 + docker-compose.yml | 2 + docs/API-RAPPROCHEMENT.md | 141 +++++++++++++++ server/dateUtils.ts | 73 ++++++++ server/externalApi.ts | 348 ++++++++++++++++++++++++++++++++++++++ server/routes.ts | 78 +-------- server/security.ts | 1 + 7 files changed, 575 insertions(+), 73 deletions(-) create mode 100644 docs/API-RAPPROCHEMENT.md create mode 100644 server/dateUtils.ts create mode 100644 server/externalApi.ts diff --git a/.env.example b/.env.example index 2a01028..a4fc949 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ ENCRYPTION_KEY= # Route de secours POST /api/emergency-admin-reset : désactivée si vide. # Ne définir qu'en cas de besoin, puis retirer. 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= diff --git a/docker-compose.yml b/docker-compose.yml index b0f4ff0..ef37976 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: POSTGRES_PASSWORD: LogiFlow2025! POSTGRES_DB: logiflow_db 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: - "3000:3000" networks: diff --git a/docs/API-RAPPROCHEMENT.md b/docs/API-RAPPROCHEMENT.md new file mode 100644 index 0000000..2e4b180 --- /dev/null +++ b/docs/API-RAPPROCHEMENT.md @@ -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: +Authorization: Bearer +``` + +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). diff --git a/server/dateUtils.ts b/server/dateUtils.ts new file mode 100644 index 0000000..e3d63ce --- /dev/null +++ b/server/dateUtils.ts @@ -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 + } +} diff --git a/server/externalApi.ts b/server/externalApi.ts new file mode 100644 index 0000000..ecaa781 --- /dev/null +++ b/server/externalApi.ts @@ -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 ". +// +// 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 { + 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 = {}; + 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" }); + }); +} diff --git a/server/routes.ts b/server/routes.ts index dd69123..b72214b 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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 { 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 diff --git a/server/security.ts b/server/security.ts index 61b61e9..65b6976 100644 --- a/server/security.ts +++ b/server/security.ts @@ -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))) {