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:
Claude committed 2026-08-14 16:02:55 +00:00
1 parent 012024a293
commit 9de717387f
9 files changed
+419 -26

No files matched your search

+54 -14
View File
@@ -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>
);
+19
View File
@@ -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
-- ============================================================================
+76
View File
@@ -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.'
);
}
}
+6 -2
View File
@@ -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,
});
}
+57
View File
@@ -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:', {
+22
View File
@@ -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);
`
}
];
+60
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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>;