diff --git a/client/src/pages/BLReconciliation.tsx b/client/src/pages/BLReconciliation.tsx index a3d4646..a02fdee 100644 --- a/client/src/pages/BLReconciliation.tsx +++ b/client/src/pages/BLReconciliation.tsx @@ -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({ + 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(); + 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 ( ); @@ -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 ( ); diff --git a/init.sql b/init.sql index 99812f9..ec03f3f 100644 --- a/init.sql +++ b/init.sql @@ -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 -- ============================================================================ diff --git a/server/crypto.ts b/server/crypto.ts new file mode 100644 index 0000000..6542f1d --- /dev/null +++ b/server/crypto.ts @@ -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:::, 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.' + ); + } +} diff --git a/server/emailService.ts b/server/emailService.ts index 20fe492..1a8ec34 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -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, }); } diff --git a/server/migrations.production.ts b/server/migrations.production.ts index 74f38cc..4ae7615 100644 --- a/server/migrations.production.ts +++ b/server/migrations.production.ts @@ -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:', { diff --git a/server/migrations.ts b/server/migrations.ts index 7512df6..0c623a8 100644 --- a/server/migrations.ts +++ b/server/migrations.ts @@ -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); + ` } ]; diff --git a/server/routes.ts b/server/routes.ts index 86ff63f..e058a62 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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,22 +2370,53 @@ export async function registerRoutes(app: Express): Promise { }); } - const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail); + // 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); + } + }; - console.log('📧 Mail fournisseur envoyé:', { - deliveryId, - supplier: delivery.supplier?.name, - to: supplierEmail, - store: group.name, - messageId: result.messageId - }); + try { + const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail); - res.json({ - success: true, - sentTo: supplierEmail, - supplierName: delivery.supplier?.name || null, - messageId: result.messageId - }); + await logAttempt('sent', { messageId: result.messageId }); + + console.log('📧 Mail fournisseur envoyé:', { + deliveryId, + supplier: delivery.supplier?.name, + to: supplierEmail, + store: group.name, + messageId: result.messageId + }); + + res.json({ + success: true, + sentTo: supplierEmail, + 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 { } }); + // 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); diff --git a/server/storage.ts b/server/storage.ts index 9ce7f28..b001e5f 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -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; clearWeatherCache(): Promise; + // Historique des mails fournisseurs (rapprochement) + createSupplierMailLog(log: InsertSupplierMailLog): Promise; + getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise; + // Webhook BAP Configuration getWebhookBapConfig(): Promise; createWebhookBapConfig(config: InsertWebhookBapConfig): Promise; @@ -428,14 +436,21 @@ export class DatabaseStorage implements IStorage { } async createGroup(groupData: InsertGroup): Promise { - 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): Promise { + 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(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 { - 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 { const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id)); - return config; + return this.decryptNocodbConfig(config); } async getActiveNocodbConfig(): Promise { @@ -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 { - 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): Promise { + 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 { @@ -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 { + const [log] = await db.insert(supplierMailLogs).values(logData).returning(); + return log; + } + + async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise { + 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 { 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 { + 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 { + 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 { // En développement, retourner une config par défaut diff --git a/shared/schema.ts b/shared/schema.ts index 0662dfc..b4d4071 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -985,6 +985,23 @@ export type WeatherSettings = typeof weatherSettings.$inferSelect; export type InsertWeatherSettings = z.infer; // 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; + +// Supplier mail logs +export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({ + id: true, + createdAt: true, +}); +export type SupplierMailLog = typeof supplierMailLogs.$inferSelect; +export type InsertSupplierMailLog = z.infer;