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 -73
View File
@@ -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