mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
feat(securite): encrypt SMTP passwords and NocoDB tokens at rest, log supplier mails
Encryption (server/crypto.ts): - AES-256-GCM with a key derived from ENCRYPTION_KEY, falling back to SESSION_SECRET so existing deployments need no new configuration - stored format enc:v1:<iv>:<tag>:<data>; encryption is idempotent and decryption passes legacy plaintext through unchanged, so nothing breaks mid-migration - tampered data or a changed key raises an explicit error instead of returning garbage - encrypt/decrypt is confined to the storage layer: group writes encrypt smtpPassword (decrypted only in emailService at connection time, never sent to the client), NocoDB config writes encrypt apiToken and reads decrypt it so the invoice verification and the admin page behave as before - startup migration sweep encrypts secrets already stored in plaintext, idempotently; the active-config log line no longer prints the token Mail history (supplier_mail_logs): - every send attempt is recorded: delivery, store, supplier, recipient, subject, status sent/failed with error, message id, user id and name; a logging failure never fails the send itself - GET /api/supplier-mail-logs restricted to the user's stores (admin may filter by store) - on the reconciliation page the mail icon turns green once a request has been sent, with the date and sender in the tooltip; clicking again resends - table created in init.sql, versioned migrations and the production startup migration, with delivery/group indexes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
9 files changed
+419
-26
No files matched your search
@@ -79,6 +79,34 @@ export default function BLReconciliation() {
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
|
||||
const { data: supplierMailLogs = [] } = useQuery<any[]>({
|
||||
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
const response = await fetch(`/api/supplier-mail-logs?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch supplier mail logs');
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Dernier envoi réussi par livraison (les logs arrivent triés du plus récent au plus ancien)
|
||||
const lastMailByDelivery = React.useMemo(() => {
|
||||
const map = new Map<number, any>();
|
||||
for (const log of supplierMailLogs) {
|
||||
if (log.status === 'sent' && !map.has(log.deliveryId)) {
|
||||
map.set(log.deliveryId, log);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [supplierMailLogs]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File d'attente des vérifications de facture
|
||||
//
|
||||
@@ -688,6 +716,8 @@ export default function BLReconciliation() {
|
||||
title: "Mail envoyé",
|
||||
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
|
||||
});
|
||||
// Rafraîchir l'historique pour marquer la ligne comme relancée
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/supplier-mail-logs'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -1164,6 +1194,7 @@ export default function BLReconciliation() {
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
const lastMail = lastMailByDelivery.get(delivery.id);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1171,22 +1202,26 @@ export default function BLReconciliation() {
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`h-8 w-8 p-0 ${
|
||||
supplierEmail
|
||||
? 'text-blue-600 hover:text-blue-700 border-blue-300'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
!supplierEmail
|
||||
? 'text-gray-400 hover:text-gray-500'
|
||||
: lastMail
|
||||
? 'text-green-600 hover:text-green-700 border-green-300'
|
||||
: 'text-blue-600 hover:text-blue-700 border-blue-300'
|
||||
}`}
|
||||
title={
|
||||
isSending
|
||||
? "Envoi en cours..."
|
||||
: supplierEmail
|
||||
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: !supplierEmail
|
||||
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: lastMail
|
||||
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
|
||||
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
<Mail className={`h-4 w-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
@@ -1502,27 +1537,32 @@ export default function BLReconciliation() {
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
const lastMail = lastMailByDelivery.get(delivery.id);
|
||||
return (
|
||||
<button
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
|
||||
supplierEmail
|
||||
? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
!supplierEmail
|
||||
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
: lastMail
|
||||
? 'text-green-600 hover:text-green-700 hover:bg-green-50'
|
||||
: 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||
}`}
|
||||
title={
|
||||
isSending
|
||||
? "Envoi en cours..."
|
||||
: supplierEmail
|
||||
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: !supplierEmail
|
||||
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
: lastMail
|
||||
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
|
||||
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" />
|
||||
<Mail className={`w-4 h-4 ${lastMail ? 'fill-green-100' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -419,6 +419,25 @@ CREATE TABLE IF NOT EXISTS "webhook_bap_config" (
|
||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Historique des mails de relance fournisseurs (rapprochement BL/Factures)
|
||||
CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"delivery_id" integer NOT NULL,
|
||||
"group_id" integer NOT NULL,
|
||||
"supplier_id" integer,
|
||||
"supplier_name" varchar(255),
|
||||
"sent_to" varchar(255) NOT NULL,
|
||||
"subject" text,
|
||||
"status" varchar(20) NOT NULL,
|
||||
"error_message" text,
|
||||
"message_id" varchar(255),
|
||||
"sent_by" varchar NOT NULL,
|
||||
"sent_by_name" varchar(255),
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- FOREIGN KEY CONSTRAINTS
|
||||
-- ============================================================================
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Chiffrement au repos des secrets applicatifs (mot de passe SMTP des
|
||||
* magasins, jeton API NocoDB).
|
||||
*
|
||||
* - AES-256-GCM (chiffrement authentifié : toute altération en base est détectée)
|
||||
* - Clé dérivée de ENCRYPTION_KEY, avec repli sur SESSION_SECRET pour que la
|
||||
* production existante fonctionne sans nouvelle variable d'environnement
|
||||
* - Format stocké : enc:v1:<iv>:<tag>:<données>, tout en base64
|
||||
* - Les valeurs héritées en clair sont acceptées en lecture (déchiffrement
|
||||
* transparent) et re-chiffrées par le balayage de démarrage
|
||||
*
|
||||
* ATTENTION : changer ENCRYPTION_KEY/SESSION_SECRET après coup rend les
|
||||
* secrets déjà chiffrés illisibles — il faudrait alors les ressaisir.
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
// Même repli figé que les sessions : mieux vaut un chiffrement à clé
|
||||
// faible qu'un stockage en clair, et la production définit toujours
|
||||
// SESSION_SECRET via docker-compose.
|
||||
return crypto.createHash('sha256').update('logiflow-fallback-secret-key').digest();
|
||||
}
|
||||
return crypto.createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function isEncryptedSecret(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffre un secret. Les valeurs vides et les valeurs déjà chiffrées sont
|
||||
* renvoyées telles quelles (idempotent : pas de double chiffrement possible).
|
||||
*/
|
||||
export function encryptSecret(value: string | null | undefined): string | null | undefined {
|
||||
if (!value || isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déchiffre un secret. Une valeur non chiffrée (héritage d'avant le
|
||||
* chiffrement) est renvoyée telle quelle.
|
||||
*/
|
||||
export function decryptSecret(value: string | null | undefined): string | null | undefined {
|
||||
if (!value || !isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parts = value.slice(PREFIX.length).split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Secret chiffré illisible : format inattendu');
|
||||
}
|
||||
|
||||
try {
|
||||
const [iv, tag, data] = parts.map(p => Buffer.from(p, 'base64'));
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import { decryptSecret } from './crypto';
|
||||
import {
|
||||
buildSupplierMailSubject,
|
||||
buildSupplierMailText,
|
||||
@@ -61,14 +62,17 @@ export function createTransporter(group: StoreSmtpConfig): Transporter {
|
||||
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const hasAuth = Boolean(group.smtpUser?.trim() && group.smtpPassword);
|
||||
// Le mot de passe est stocké chiffré (AES-256-GCM) : déchiffré uniquement
|
||||
// ici, au moment de la connexion au serveur SMTP
|
||||
const smtpPassword = decryptSecret(group.smtpPassword);
|
||||
const hasAuth = Boolean(group.smtpUser?.trim() && smtpPassword);
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: group.smtpHost!.trim(),
|
||||
port: Number(group.smtpPort),
|
||||
secure: Boolean(group.smtpSecure),
|
||||
auth: hasAuth
|
||||
? { user: group.smtpUser!.trim(), pass: group.smtpPassword! }
|
||||
? { user: group.smtpUser!.trim(), pass: smtpPassword! }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client } from 'pg';
|
||||
import { encryptSecret, isEncryptedSecret } from './crypto.js';
|
||||
|
||||
export async function runProductionMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -76,6 +77,62 @@ export async function runProductionMigrations() {
|
||||
`);
|
||||
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
|
||||
|
||||
// Historique des mails de relance fournisseurs
|
||||
console.log('🔄 MIGRATION: Ensuring supplier_mail_logs table...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
delivery_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
supplier_name VARCHAR(255),
|
||||
sent_to VARCHAR(255) NOT NULL,
|
||||
subject TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
message_id VARCHAR(255),
|
||||
sent_by VARCHAR NOT NULL,
|
||||
sent_by_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`);
|
||||
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
||||
|
||||
// Chiffrement au repos des secrets encore stockés en clair
|
||||
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
||||
// les valeurs déjà au format enc:v1: sont ignorées.
|
||||
console.log('🔄 MIGRATION: Encrypting plaintext secrets at rest...');
|
||||
let encryptedCount = 0;
|
||||
|
||||
const plainSmtp = await client.query(`
|
||||
SELECT id, smtp_password FROM groups
|
||||
WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'
|
||||
`);
|
||||
for (const row of plainSmtp.rows) {
|
||||
await client.query('UPDATE groups SET smtp_password = $1 WHERE id = $2', [
|
||||
encryptSecret(row.smtp_password),
|
||||
row.id,
|
||||
]);
|
||||
encryptedCount++;
|
||||
}
|
||||
|
||||
const plainTokens = await client.query(`
|
||||
SELECT id, api_token FROM nocodb_config
|
||||
WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'
|
||||
`);
|
||||
for (const row of plainTokens.rows) {
|
||||
if (isEncryptedSecret(row.api_token)) continue;
|
||||
await client.query('UPDATE nocodb_config SET api_token = $1 WHERE id = $2', [
|
||||
encryptSecret(row.api_token),
|
||||
row.id,
|
||||
]);
|
||||
encryptedCount++;
|
||||
}
|
||||
|
||||
console.log(`✅ MIGRATION: Secrets encryption done (${encryptedCount} value(s) encrypted this run)`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
|
||||
console.error('❌ MIGRATION ERROR: Error details:', {
|
||||
|
||||
@@ -84,6 +84,28 @@ WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
|
||||
`
|
||||
},
|
||||
{
|
||||
filename: '20260814000001_create_supplier_mail_logs.sql',
|
||||
content: `
|
||||
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
delivery_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
supplier_name VARCHAR(255),
|
||||
sent_to VARCHAR(255) NOT NULL,
|
||||
subject TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
message_id VARCHAR(255),
|
||||
sent_by VARCHAR NOT NULL,
|
||||
sent_by_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
verifySmtpConfig,
|
||||
getMissingSmtpFields,
|
||||
} from "./emailService";
|
||||
import { buildSupplierMailSubject } from "@shared/supplierMail";
|
||||
import { db, pool } from "./db";
|
||||
import { createRequire } from "module";
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -2369,8 +2370,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
});
|
||||
}
|
||||
|
||||
// Historisation de la tentative, succès comme échec — un échec de
|
||||
// journalisation ne doit jamais faire échouer (ni annuler) l'envoi
|
||||
const senderName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
|| user.username || user.id;
|
||||
const logAttempt = async (status: 'sent' | 'failed', extra: { messageId?: string; errorMessage?: string }) => {
|
||||
try {
|
||||
await storage.createSupplierMailLog({
|
||||
deliveryId,
|
||||
groupId: delivery.groupId,
|
||||
supplierId: delivery.supplierId ?? null,
|
||||
supplierName: delivery.supplier?.name || null,
|
||||
sentTo: supplierEmail,
|
||||
subject: buildSupplierMailSubject(delivery as any),
|
||||
status,
|
||||
errorMessage: extra.errorMessage || null,
|
||||
messageId: extra.messageId || null,
|
||||
sentBy: user.id,
|
||||
sentByName: senderName,
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('⚠️ Historisation du mail fournisseur impossible:', logError);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
|
||||
|
||||
await logAttempt('sent', { messageId: result.messageId });
|
||||
|
||||
console.log('📧 Mail fournisseur envoyé:', {
|
||||
deliveryId,
|
||||
supplier: delivery.supplier?.name,
|
||||
@@ -2385,6 +2413,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
supplierName: delivery.supplier?.name || null,
|
||||
messageId: result.messageId
|
||||
});
|
||||
} catch (sendError: any) {
|
||||
await logAttempt('failed', { errorMessage: sendError?.message || 'Erreur inconnue' });
|
||||
throw sendError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur envoi mail fournisseur:", error);
|
||||
res.status(500).json({
|
||||
@@ -2393,6 +2425,34 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Historique des relances fournisseurs, restreint aux magasins de l'utilisateur
|
||||
app.get('/api/supplier-mail-logs', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const deliveryId = req.query.deliveryId ? parseInt(req.query.deliveryId as string) : undefined;
|
||||
|
||||
let groupIds: number[] | undefined;
|
||||
if (user.role !== 'admin') {
|
||||
groupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
if (groupIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
} else if (req.query.storeId) {
|
||||
groupIds = [parseInt(req.query.storeId as string)];
|
||||
}
|
||||
|
||||
const logs = await storage.getSupplierMailLogs(groupIds, deliveryId);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error("Erreur lecture historique mails fournisseurs:", error);
|
||||
res.status(500).json({ message: "Failed to fetch supplier mail logs" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
|
||||
+100
-10
@@ -13,6 +13,7 @@ import {
|
||||
dashboardMessages,
|
||||
announcements,
|
||||
nocodbConfig,
|
||||
supplierMailLogs,
|
||||
invoiceVerificationCache,
|
||||
reconciliationComments,
|
||||
savTickets,
|
||||
@@ -58,6 +59,8 @@ import {
|
||||
type ReconciliationCommentWithRelations,
|
||||
type NocodbConfig,
|
||||
type InsertNocodbConfig,
|
||||
type SupplierMailLog,
|
||||
type InsertSupplierMailLog,
|
||||
type InvoiceVerificationCache,
|
||||
type InsertInvoiceVerificationCache,
|
||||
type SavTicket,
|
||||
@@ -83,6 +86,7 @@ import {
|
||||
type Contact,
|
||||
type InsertContact,
|
||||
} from "@shared/schema";
|
||||
import { encryptSecret, decryptSecret } from "./crypto";
|
||||
import { db } from "./db";
|
||||
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
|
||||
import { getAnnouncementStorage } from "./announcementStorage";
|
||||
@@ -270,6 +274,10 @@ export interface IStorage {
|
||||
deleteOldWeatherData(daysToKeep: number): Promise<void>;
|
||||
clearWeatherCache(): Promise<void>;
|
||||
|
||||
// Historique des mails fournisseurs (rapprochement)
|
||||
createSupplierMailLog(log: InsertSupplierMailLog): Promise<SupplierMailLog>;
|
||||
getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]>;
|
||||
|
||||
// Webhook BAP Configuration
|
||||
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
|
||||
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
|
||||
@@ -428,14 +436,21 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
async createGroup(groupData: InsertGroup): Promise<Group> {
|
||||
const [group] = await db.insert(groups).values(groupData).returning();
|
||||
// Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au
|
||||
// moment de l'envoi d'un mail (emailService)
|
||||
const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) };
|
||||
const [group] = await db.insert(groups).values(values).returning();
|
||||
return group;
|
||||
}
|
||||
|
||||
async updateGroup(id: number, groupData: Partial<InsertGroup>): Promise<Group> {
|
||||
const values = { ...groupData };
|
||||
if (values.smtpPassword !== undefined) {
|
||||
values.smtpPassword = encryptSecret(values.smtpPassword);
|
||||
}
|
||||
const [group] = await db
|
||||
.update(groups)
|
||||
.set({ ...groupData, updatedAt: new Date() })
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(groups.id, id))
|
||||
.returning();
|
||||
return group;
|
||||
@@ -1622,13 +1637,26 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
// NocoDB Configuration operations
|
||||
// Le jeton API est chiffré en base ; les lecteurs (vérification de facture,
|
||||
// page d'admin) reçoivent la valeur en clair comme avant le chiffrement
|
||||
private decryptNocodbConfig<T extends NocodbConfig | undefined>(config: T): T {
|
||||
if (!config) return config;
|
||||
try {
|
||||
return { ...config, apiToken: decryptSecret(config.apiToken) } as T;
|
||||
} catch (error) {
|
||||
console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error);
|
||||
return { ...config, apiToken: '' } as T;
|
||||
}
|
||||
}
|
||||
|
||||
async getNocodbConfigs(): Promise<NocodbConfig[]> {
|
||||
return await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
|
||||
const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
|
||||
return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c));
|
||||
}
|
||||
|
||||
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
|
||||
const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id));
|
||||
return config;
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
|
||||
@@ -1639,8 +1667,9 @@ export class DatabaseStorage implements IStorage {
|
||||
.where(eq(nocodbConfig.isActive, true))
|
||||
.limit(1);
|
||||
|
||||
console.log('🔧 Configuration NocoDB active récupérée:', config);
|
||||
return config;
|
||||
// Ne pas tracer le jeton en clair dans les logs
|
||||
console.log('🔧 Configuration NocoDB active récupérée:', config?.id, config?.name);
|
||||
return this.decryptNocodbConfig(config);
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur récupération config NocoDB:', error);
|
||||
return undefined;
|
||||
@@ -1648,17 +1677,22 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
async createNocodbConfig(configData: InsertNocodbConfig): Promise<NocodbConfig> {
|
||||
const [config] = await db.insert(nocodbConfig).values(configData).returning();
|
||||
return config;
|
||||
const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' };
|
||||
const [config] = await db.insert(nocodbConfig).values(values).returning();
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async updateNocodbConfig(id: number, configData: Partial<InsertNocodbConfig>): Promise<NocodbConfig> {
|
||||
const values = { ...configData };
|
||||
if (values.apiToken !== undefined) {
|
||||
values.apiToken = encryptSecret(values.apiToken) || '';
|
||||
}
|
||||
const [config] = await db
|
||||
.update(nocodbConfig)
|
||||
.set({ ...configData, updatedAt: new Date() })
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(nocodbConfig.id, id))
|
||||
.returning();
|
||||
return config;
|
||||
return this.decryptNocodbConfig(config);
|
||||
}
|
||||
|
||||
async deleteNocodbConfig(id: number): Promise<void> {
|
||||
@@ -2912,6 +2946,29 @@ export class DatabaseStorage implements IStorage {
|
||||
console.log('🧹 Weather cache cleared due to location change');
|
||||
}
|
||||
|
||||
// Historique des mails fournisseurs (rapprochement)
|
||||
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
|
||||
const [log] = await db.insert(supplierMailLogs).values(logData).returning();
|
||||
return log;
|
||||
}
|
||||
|
||||
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
|
||||
const conditions = [];
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
conditions.push(inArray(supplierMailLogs.groupId, groupIds));
|
||||
}
|
||||
if (deliveryId !== undefined) {
|
||||
conditions.push(eq(supplierMailLogs.deliveryId, deliveryId));
|
||||
}
|
||||
|
||||
let query = db.select().from(supplierMailLogs).$dynamic();
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
// Borné : la page de rapprochement n'a besoin que de l'historique récent
|
||||
return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500);
|
||||
}
|
||||
|
||||
// Webhook BAP Configuration
|
||||
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
|
||||
const [config] = await db.select().from(webhookBapConfig).limit(1);
|
||||
@@ -5209,6 +5266,39 @@ export class MemStorage implements IStorage {
|
||||
console.log('🧹 DEV: Weather cache cleared due to location change');
|
||||
}
|
||||
|
||||
// Historique des mails fournisseurs (en mémoire pour le développement)
|
||||
private supplierMailLogsStore: SupplierMailLog[] = [];
|
||||
private supplierMailLogIdCounter = 1;
|
||||
|
||||
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
|
||||
const log: SupplierMailLog = {
|
||||
id: this.supplierMailLogIdCounter++,
|
||||
deliveryId: logData.deliveryId,
|
||||
groupId: logData.groupId,
|
||||
supplierId: logData.supplierId ?? null,
|
||||
supplierName: logData.supplierName ?? null,
|
||||
sentTo: logData.sentTo,
|
||||
subject: logData.subject ?? null,
|
||||
status: logData.status,
|
||||
errorMessage: logData.errorMessage ?? null,
|
||||
messageId: logData.messageId ?? null,
|
||||
sentBy: logData.sentBy,
|
||||
sentByName: logData.sentByName ?? null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
this.supplierMailLogsStore.unshift(log);
|
||||
return log;
|
||||
}
|
||||
|
||||
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
|
||||
return this.supplierMailLogsStore
|
||||
.filter(log =>
|
||||
(!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) &&
|
||||
(deliveryId === undefined || log.deliveryId === deliveryId)
|
||||
)
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
// Webhook BAP Configuration
|
||||
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
|
||||
// En développement, retourner une config par défaut
|
||||
|
||||
@@ -985,6 +985,23 @@ export type WeatherSettings = typeof weatherSettings.$inferSelect;
|
||||
export type InsertWeatherSettings = z.infer<typeof insertWeatherSettingsSchema>;
|
||||
|
||||
// Configuration Webhook BAP
|
||||
// Historique des mails de relance envoyés aux fournisseurs (rapprochement)
|
||||
export const supplierMailLogs = pgTable("supplier_mail_logs", {
|
||||
id: serial("id").primaryKey(),
|
||||
deliveryId: integer("delivery_id").notNull(),
|
||||
groupId: integer("group_id").notNull(),
|
||||
supplierId: integer("supplier_id"),
|
||||
supplierName: varchar("supplier_name", { length: 255 }), // figé au moment de l'envoi
|
||||
sentTo: varchar("sent_to", { length: 255 }).notNull(), // adresse destinataire
|
||||
subject: text("subject"),
|
||||
status: varchar("status", { length: 20 }).notNull(), // 'sent' | 'failed'
|
||||
errorMessage: text("error_message"),
|
||||
messageId: varchar("message_id", { length: 255 }),
|
||||
sentBy: varchar("sent_by").notNull(), // id utilisateur
|
||||
sentByName: varchar("sent_by_name", { length: 255 }), // nom lisible, figé
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const webhookBapConfig = pgTable("webhook_bap_config", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull().default("Configuration BAP"),
|
||||
@@ -1005,3 +1022,11 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
|
||||
// Webhook BAP Types
|
||||
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
|
||||
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
|
||||
|
||||
// Supplier mail logs
|
||||
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
export type SupplierMailLog = typeof supplierMailLogs.$inferSelect;
|
||||
export type InsertSupplierMailLog = z.infer<typeof insertSupplierMailLogSchema>;
|
||||
Reference in new issue
Block a user