mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
feat(mails): send supplier document requests over each store's own SMTP
Replaces the mailto: link with a server-side send, so the message carries the store's signature and logo instead of depending on each workstation's Outlook. Store record (groups): - address, phone and logo (data URI, 200 KB cap) feed the mail signature - per-store SMTP settings: host, port, SSL/STARTTLS, credentials, sender address and display name, with an enable switch - "test connection" button verifies the server without sending anything - the empty-form literal, previously repeated five times, becomes one constant Sending: - nodemailer transport built per store from its own settings - multipart mail: plain-text alternative plus HTML whose signature embeds the logo as an inline CID attachment, which Outlook renders without the remote image blocking that a data: URI would hit - delivery details are HTML-escaped - Reply-To set to the store address; the row shows a spinner and refuses a second click while a send is in flight Credentials: - the SMTP password is never returned to the client; a response-layer sanitizer strips it from every /api payload and replaces it with a smtpPasswordSet flag, covering the ten-plus queries that join full group rows into deliveries, orders and user relations - an empty password field on save keeps the stored one rather than clearing it Verified end-to-end against a local SMTP server: transport, auth, From/Reply-To, multipart structure and the inline logo attachment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
14 files changed
+994
-209
No files matched your search
+2
-1
@@ -61,7 +61,8 @@ RUN npx esbuild server/index.production.ts \
|
||||
--external:express-rate-limit \
|
||||
--external:memoizee \
|
||||
--external:nanoid \
|
||||
--external:date-fns
|
||||
--external:date-fns \
|
||||
--external:nodemailer
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
|
||||
/**
|
||||
* Génération des emails de relance fournisseur (rapprochement BL/Factures).
|
||||
* Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook)
|
||||
* avec le destinataire, l'objet et le corps du message déjà préremplis.
|
||||
*/
|
||||
|
||||
export interface SupplierMailDelivery {
|
||||
blNumber?: string | null;
|
||||
blAmount?: string | number | null;
|
||||
invoiceReference?: string | null;
|
||||
scheduledDate?: string | Date | null;
|
||||
deliveredDate?: string | Date | null;
|
||||
supplier?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
contact?: string | null;
|
||||
} | null;
|
||||
group?: { name?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Objet du mail : identifie la livraison concernée.
|
||||
*/
|
||||
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||
const formattedDate = deliveryDate
|
||||
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||
: "";
|
||||
|
||||
const reference = delivery.blNumber
|
||||
? `BL n° ${delivery.blNumber}`
|
||||
: formattedDate
|
||||
? `Livraison du ${formattedDate}`
|
||||
: "Livraison";
|
||||
|
||||
const parts = [
|
||||
"Demande de facture (PDF) ou BL (Excel)",
|
||||
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
|
||||
];
|
||||
|
||||
if (delivery.group?.name) {
|
||||
parts.push(delivery.group.name);
|
||||
}
|
||||
|
||||
return parts.join(" - ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Corps du mail : rappel des documents attendus + récapitulatif de la livraison.
|
||||
* Seules les informations réellement renseignées sont listées.
|
||||
*/
|
||||
export function buildSupplierMailBody(delivery: SupplierMailDelivery): string {
|
||||
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||
const formattedDate = deliveryDate
|
||||
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||
: "";
|
||||
|
||||
const blAmount =
|
||||
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
|
||||
? parseFloat(String(delivery.blAmount))
|
||||
: null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`);
|
||||
if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`);
|
||||
if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`);
|
||||
if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`);
|
||||
if (blAmount !== null && !isNaN(blAmount)) {
|
||||
details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`);
|
||||
}
|
||||
if (delivery.invoiceReference) {
|
||||
details.push(`- Référence facture : ${delivery.invoiceReference}`);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Bonjour,",
|
||||
"",
|
||||
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||
"",
|
||||
"- la facture au format PDF, ou",
|
||||
"- le bon de livraison au format Excel",
|
||||
"",
|
||||
"Livraison concernée :",
|
||||
...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]),
|
||||
"",
|
||||
"Vous en remerciant par avance.",
|
||||
"",
|
||||
// Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés
|
||||
// par la signature configurée dans Outlook sur le poste.
|
||||
"Cordialement,",
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le lien mailto complet (destinataire + objet + corps préremplis).
|
||||
*/
|
||||
export function buildSupplierMailtoUrl(
|
||||
delivery: SupplierMailDelivery,
|
||||
supplierEmail: string
|
||||
): string {
|
||||
const subject = buildSupplierMailSubject(delivery);
|
||||
// RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A),
|
||||
// sinon Outlook peut coller les lignes les unes aux autres.
|
||||
const body = buildSupplierMailBody(delivery).replace(/\n/g, "\r\n");
|
||||
|
||||
return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin).
|
||||
*/
|
||||
export function openMailClient(mailtoUrl: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = mailtoUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
@@ -68,6 +67,8 @@ export default function BLReconciliation() {
|
||||
// État pour le système de vérification de facture
|
||||
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
||||
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
|
||||
// Livraisons dont le mail fournisseur est en cours d'envoi
|
||||
const [sendingMailDeliveries, setSendingMailDeliveries] = useState<Set<number>>(new Set());
|
||||
|
||||
// État pour le modal de commentaire
|
||||
const [showCommentModal, setShowCommentModal] = useState(false);
|
||||
@@ -676,8 +677,34 @@ export default function BLReconciliation() {
|
||||
return supplier?.email ? String(supplier.email).trim() : '';
|
||||
};
|
||||
|
||||
// Ouvre Outlook (client mail par défaut) avec un message prérempli
|
||||
// demandant la facture au format PDF ou le BL au format Excel
|
||||
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
|
||||
// Le mail part du serveur SMTP configuré sur la fiche du magasin.
|
||||
const sendSupplierMailMutation = useMutation({
|
||||
mutationFn: async (delivery: any) => {
|
||||
return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST');
|
||||
},
|
||||
onSuccess: (result: any) => {
|
||||
toast({
|
||||
title: "Mail envoyé",
|
||||
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Envoi impossible",
|
||||
description: error?.message || "Le mail n'a pas pu être envoyé",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, delivery: any) => {
|
||||
setSendingMailDeliveries(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(delivery.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRequestDocumentsByEmail = (delivery: any) => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
|
||||
@@ -690,8 +717,12 @@ export default function BLReconciliation() {
|
||||
return;
|
||||
}
|
||||
|
||||
const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail);
|
||||
openMailClient(mailtoUrl);
|
||||
// Envoi direct : on bloque la ligne le temps de la requête pour éviter
|
||||
// qu'un double clic ne déclenche deux mails au même fournisseur.
|
||||
if (sendingMailDeliveries.has(delivery.id)) return;
|
||||
|
||||
setSendingMailDeliveries(prev => new Set(prev).add(delivery.id));
|
||||
sendSupplierMailMutation.mutate(delivery);
|
||||
};
|
||||
|
||||
const handleQuickValidate = async (delivery: any) => {
|
||||
@@ -1132,10 +1163,12 @@ export default function BLReconciliation() {
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`h-8 w-8 p-0 ${
|
||||
supplierEmail
|
||||
@@ -1143,12 +1176,18 @@ export default function BLReconciliation() {
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
}`}
|
||||
title={
|
||||
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'}`
|
||||
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'}`
|
||||
}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{isSending ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
@@ -1462,8 +1501,10 @@ export default function BLReconciliation() {
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{(() => {
|
||||
const supplierEmail = getSupplierEmail(delivery);
|
||||
const isSending = sendingMailDeliveries.has(delivery.id);
|
||||
return (
|
||||
<button
|
||||
disabled={isSending}
|
||||
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
|
||||
supplierEmail
|
||||
@@ -1471,12 +1512,18 @@ export default function BLReconciliation() {
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
title={
|
||||
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'}`
|
||||
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'}`
|
||||
}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{isSending ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
+308
-70
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -33,6 +34,38 @@ const colorOptions = [
|
||||
{ value: '#00796B', label: 'Sarcelle' },
|
||||
];
|
||||
|
||||
// Taille maximale du logo magasin : il est encodé en base64 dans la fiche
|
||||
// et joint à chaque mail, un fichier lourd alourdirait tous les envois.
|
||||
const MAX_LOGO_KB = 200;
|
||||
|
||||
// Valeurs par défaut du formulaire magasin (création et réinitialisations)
|
||||
const EMPTY_GROUP_FORM = {
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
// Coordonnées reprises dans la signature des mails fournisseurs
|
||||
address: "",
|
||||
phone: "",
|
||||
logo: "",
|
||||
// Configuration SMTP propre au magasin
|
||||
smtpEnabled: false,
|
||||
smtpHost: "",
|
||||
smtpPort: "",
|
||||
smtpSecure: false,
|
||||
smtpUser: "",
|
||||
smtpPassword: "",
|
||||
smtpSenderEmail: "",
|
||||
smtpSenderName: "",
|
||||
};
|
||||
|
||||
export default function Groups() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
@@ -42,19 +75,7 @@ export default function Groups() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
@@ -97,19 +118,7 @@ export default function Groups() {
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -143,19 +152,7 @@ export default function Groups() {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -177,6 +174,53 @@ export default function Groups() {
|
||||
},
|
||||
});
|
||||
|
||||
// Test de la connexion SMTP du magasin (aucun message envoyé)
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async (groupId: number) => {
|
||||
return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST');
|
||||
},
|
||||
onSuccess: (result: any) => {
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: result?.message || "Le serveur SMTP répond correctement",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Échec de la connexion",
|
||||
description: error?.message || "Impossible de joindre le serveur SMTP",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Lecture du logo en data URI : stocké tel quel sur la fiche magasin
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > MAX_LOGO_KB * 1024) {
|
||||
toast({
|
||||
title: "Logo trop volumineux",
|
||||
description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => handleChange('logo', String(reader.result || ''));
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
title: "Lecture impossible",
|
||||
description: "Le fichier n'a pas pu être lu",
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/groups/${id}`, "DELETE");
|
||||
@@ -225,19 +269,7 @@ export default function Groups() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
@@ -255,6 +287,18 @@ export default function Groups() {
|
||||
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
|
||||
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
|
||||
webhookUrl: group.webhookUrl || "",
|
||||
address: (group as any).address || "",
|
||||
phone: (group as any).phone || "",
|
||||
logo: (group as any).logo || "",
|
||||
smtpEnabled: Boolean((group as any).smtpEnabled),
|
||||
smtpHost: (group as any).smtpHost || "",
|
||||
smtpPort: (group as any).smtpPort?.toString() || "",
|
||||
smtpSecure: Boolean((group as any).smtpSecure),
|
||||
smtpUser: (group as any).smtpUser || "",
|
||||
// Jamais renvoyé par l'API : vide = mot de passe inchangé
|
||||
smtpPassword: "",
|
||||
smtpSenderEmail: (group as any).smtpSenderEmail || "",
|
||||
smtpSenderName: (group as any).smtpSenderName || "",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
@@ -289,11 +333,17 @@ export default function Groups() {
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||
smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null,
|
||||
};
|
||||
|
||||
// Mot de passe laissé vide = on conserve celui déjà enregistré
|
||||
if (!formData.smtpPassword) {
|
||||
delete submitData.smtpPassword;
|
||||
}
|
||||
|
||||
if (selectedGroup) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
@@ -301,7 +351,7 @@ export default function Groups() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const handleChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
@@ -683,27 +733,215 @@ export default function Groups() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Coordonnées du magasin (signature des mails) */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Reprises dans la signature des mails envoyés aux fournisseurs.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="address">Adresse</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder={"12 rue des Fleurs\n54000 Nancy"}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Téléphone</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="03 83 00 00 00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logo">Logo</Label>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
{formData.logo ? (
|
||||
<img
|
||||
src={formData.logo}
|
||||
alt="Logo du magasin"
|
||||
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
|
||||
Aucun logo
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleLogoChange}
|
||||
className="text-xs"
|
||||
/>
|
||||
{formData.logo && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-red-600 hover:text-red-700"
|
||||
onClick={() => handleChange('logo', '')}
|
||||
>
|
||||
Retirer le logo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration SMTP du magasin */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-purple-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
|
||||
</div>
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtpEnabled}
|
||||
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Activé</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
|
||||
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
|
||||
</p>
|
||||
|
||||
{formData.smtpEnabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
|
||||
<Input
|
||||
id="smtpHost"
|
||||
value={formData.smtpHost}
|
||||
onChange={(e) => handleChange('smtpHost', e.target.value)}
|
||||
placeholder="smtp.office365.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpPort">Port *</Label>
|
||||
<Input
|
||||
id="smtpPort"
|
||||
type="number"
|
||||
value={formData.smtpPort}
|
||||
onChange={(e) => handleChange('smtpPort', e.target.value)}
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtpSecure}
|
||||
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="smtpUser">Identifiant</Label>
|
||||
<Input
|
||||
id="smtpUser"
|
||||
value={formData.smtpUser}
|
||||
onChange={(e) => handleChange('smtpUser', e.target.value)}
|
||||
placeholder="magasin@lafoirfouille.fr"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpPassword">Mot de passe</Label>
|
||||
<Input
|
||||
id="smtpPassword"
|
||||
type="password"
|
||||
value={formData.smtpPassword}
|
||||
onChange={(e) => handleChange('smtpPassword', e.target.value)}
|
||||
placeholder={
|
||||
selectedGroup && (selectedGroup as any).smtpPasswordSet
|
||||
? "Inchangé"
|
||||
: "Mot de passe"
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
|
||||
<Input
|
||||
id="smtpSenderEmail"
|
||||
type="email"
|
||||
value={formData.smtpSenderEmail}
|
||||
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
|
||||
placeholder="magasin@lafoirfouille.fr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="smtpSenderName">Nom affiché</Label>
|
||||
<Input
|
||||
id="smtpSenderName"
|
||||
value={formData.smtpSenderName}
|
||||
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
|
||||
placeholder={formData.name || "LaFoir'Fouille Nancy"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGroup && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
|
||||
>
|
||||
{testSmtpMutation.isPending
|
||||
? "Test en cours..."
|
||||
: "✉️ Tester la connexion SMTP"}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">
|
||||
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "",
|
||||
nocodbBlColumnName: "",
|
||||
nocodbAmountColumnName: "",
|
||||
nocodbInvoiceAmountTTCColumnName: "",
|
||||
nocodbSupplierColumnName: "",
|
||||
nocodbDueDateColumnName: "",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setFormData({ ...EMPTY_GROUP_FORM });
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
|
||||
@@ -46,6 +46,19 @@ CREATE TABLE IF NOT EXISTS "groups" (
|
||||
"nocodb_supplier_column_name" varchar,
|
||||
"nocodb_due_date_column_name" varchar,
|
||||
"webhook_url" varchar(500),
|
||||
-- Coordonnées du magasin (signature des mails fournisseurs)
|
||||
"address" text,
|
||||
"phone" varchar(50),
|
||||
"logo" text,
|
||||
-- Configuration SMTP propre au magasin
|
||||
"smtp_enabled" boolean DEFAULT false,
|
||||
"smtp_host" varchar(255),
|
||||
"smtp_port" integer,
|
||||
"smtp_secure" boolean DEFAULT false,
|
||||
"smtp_user" varchar(255),
|
||||
"smtp_password" varchar(255),
|
||||
"smtp_sender_email" varchar(255),
|
||||
"smtp_sender_name" varchar(255),
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
Generated
+21
@@ -78,6 +78,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -111,6 +112,7 @@
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.27",
|
||||
@@ -3839,6 +3841,16 @@
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@@ -7349,6 +7361,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -113,6 +114,7 @@
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.27",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import {
|
||||
buildSupplierMailSubject,
|
||||
buildSupplierMailText,
|
||||
buildSupplierMailHtml,
|
||||
type SupplierMailDelivery,
|
||||
} from '@shared/supplierMail';
|
||||
|
||||
/**
|
||||
* Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin.
|
||||
* Chaque magasin renseigne son serveur, ses identifiants et son adresse
|
||||
* d'expédition sur sa fiche : aucun compte global n'est utilisé.
|
||||
*/
|
||||
|
||||
export interface StoreSmtpConfig {
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
logo?: string | null;
|
||||
smtpEnabled?: boolean | null;
|
||||
smtpHost?: string | null;
|
||||
smtpPort?: number | null;
|
||||
smtpSecure?: boolean | null;
|
||||
smtpUser?: string | null;
|
||||
smtpPassword?: string | null;
|
||||
smtpSenderEmail?: string | null;
|
||||
smtpSenderName?: string | null;
|
||||
}
|
||||
|
||||
export class SmtpConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SmtpConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le magasin dispose d'une configuration exploitable.
|
||||
* Renvoie la liste des champs manquants plutôt qu'un simple booléen,
|
||||
* pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner.
|
||||
*/
|
||||
export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] {
|
||||
if (!group) return ['magasin'];
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!group.smtpHost?.trim()) missing.push('serveur SMTP');
|
||||
if (!group.smtpPort) missing.push('port SMTP');
|
||||
if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le transporteur nodemailer d'un magasin.
|
||||
* L'authentification est optionnelle : certains relais internes acceptent
|
||||
* les envois sans identifiants.
|
||||
*/
|
||||
export function createTransporter(group: StoreSmtpConfig): Transporter {
|
||||
const missing = getMissingSmtpFields(group);
|
||||
if (missing.length > 0) {
|
||||
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const hasAuth = Boolean(group.smtpUser?.trim() && group.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! }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adresse "De :" du magasin, avec son nom affiché. */
|
||||
function buildFromAddress(group: StoreSmtpConfig): string {
|
||||
const senderEmail = group.smtpSenderEmail!.trim();
|
||||
const senderName = group.smtpSenderName?.trim() || group.name?.trim();
|
||||
return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le contenu binaire d'un logo stocké en data URI.
|
||||
* Renvoie null si le logo est absent ou dans un format inattendu.
|
||||
*/
|
||||
function parseLogoDataUri(logo: string | null | undefined) {
|
||||
if (!logo) return null;
|
||||
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, mimeType, base64] = match;
|
||||
const extension = mimeType.split('/')[1]?.split('+')[0] || 'png';
|
||||
|
||||
try {
|
||||
return {
|
||||
content: Buffer.from(base64, 'base64'),
|
||||
contentType: mimeType,
|
||||
filename: `logo.${extension}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la configuration SMTP d'un magasin sans envoyer de message.
|
||||
*/
|
||||
export async function verifySmtpConfig(group: StoreSmtpConfig): Promise<void> {
|
||||
const transporter = createTransporter(group);
|
||||
try {
|
||||
await transporter.verify();
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendSupplierMailResult {
|
||||
messageId: string;
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison.
|
||||
*/
|
||||
export async function sendSupplierDocumentRequest(
|
||||
group: StoreSmtpConfig,
|
||||
delivery: SupplierMailDelivery,
|
||||
supplierEmail: string
|
||||
): Promise<SendSupplierMailResult> {
|
||||
const transporter = createTransporter(group);
|
||||
|
||||
try {
|
||||
const store = {
|
||||
name: group.name,
|
||||
address: group.address,
|
||||
phone: group.phone,
|
||||
email: group.smtpSenderEmail,
|
||||
};
|
||||
|
||||
const logo = parseLogoDataUri(group.logo);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: buildFromAddress(group),
|
||||
to: supplierEmail.trim(),
|
||||
replyTo: group.smtpSenderEmail!.trim(),
|
||||
subject: buildSupplierMailSubject(delivery),
|
||||
text: buildSupplierMailText(delivery, store),
|
||||
html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }),
|
||||
attachments: logo
|
||||
? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }]
|
||||
: [],
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: info.messageId,
|
||||
accepted: (info.accepted || []).map(String),
|
||||
rejected: (info.rejected || []).map(String),
|
||||
};
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,24 @@ export async function runProductionMigrations() {
|
||||
} else {
|
||||
console.log('✅ MIGRATION: Priority column already exists, skipping migration');
|
||||
}
|
||||
|
||||
|
||||
// Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs)
|
||||
console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...');
|
||||
await client.query(`
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
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);
|
||||
`);
|
||||
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
|
||||
console.error('❌ MIGRATION ERROR: Error details:', {
|
||||
|
||||
+19
-1
@@ -60,12 +60,30 @@ export async function runMigrations() {
|
||||
);
|
||||
|
||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
|
||||
SELECT
|
||||
SELECT
|
||||
'Configuration BAP',
|
||||
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
||||
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
|
||||
true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
},
|
||||
{
|
||||
filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql',
|
||||
content: `
|
||||
-- Coordonnées du magasin (signature des mails fournisseurs)
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
-- Configuration SMTP propre à chaque magasin
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
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);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
import {
|
||||
sendSupplierDocumentRequest,
|
||||
verifySmtpConfig,
|
||||
getMissingSmtpFields,
|
||||
} from "./emailService";
|
||||
import { db, pool } from "./db";
|
||||
import { createRequire } from "module";
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -11,6 +17,7 @@ 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;
|
||||
@@ -139,6 +146,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
console.log('🌍 Environment detected:', environment);
|
||||
|
||||
// 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
|
||||
// nettoie une seule fois à la sortie. Le client reçoit à la place un booléen
|
||||
// smtpPasswordSet lui indiquant si un mot de passe est enregistré.
|
||||
app.use('/api', (req, res, next) => {
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = (body: any) => originalJson(stripSmtpPassword(body));
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint for Docker
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
@@ -1042,6 +1062,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const data = insertGroupSchema.partial().parse(req.body);
|
||||
|
||||
// Le mot de passe SMTP n'est jamais renvoyé au client : un champ vide
|
||||
// signifie "inchangé", pas "effacer". On ne l'écrase que s'il est fourni.
|
||||
if (!data.smtpPassword) {
|
||||
delete (data as any).smtpPassword;
|
||||
}
|
||||
|
||||
const group = await storage.updateGroup(id, data);
|
||||
res.json(group);
|
||||
} catch (error: any) {
|
||||
@@ -1050,6 +1077,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Test de la configuration SMTP d'un magasin (aucun message envoyé)
|
||||
app.post('/api/groups/:id/test-smtp', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'manager')) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const group = await storage.getGroup(id);
|
||||
if (!group) {
|
||||
return res.status(404).json({ message: "Magasin introuvable" });
|
||||
}
|
||||
|
||||
const missing = getMissingSmtpFields(group as any);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Configuration incomplète : ${missing.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
await verifySmtpConfig(group as any);
|
||||
res.json({ success: true, message: "Connexion au serveur SMTP réussie" });
|
||||
} catch (error: any) {
|
||||
console.error("Erreur test SMTP:", error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error?.message || "Impossible de joindre le serveur SMTP"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/groups/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
@@ -2254,6 +2314,85 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
});
|
||||
|
||||
// Route de vérification de facture NocoDB
|
||||
// Envoi au fournisseur de la demande de facture (PDF) ou de BL (Excel)
|
||||
// via le serveur SMTP configuré sur la fiche du magasin de la livraison
|
||||
app.post('/api/deliveries/:id/send-supplier-mail', 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 = parseInt(req.params.id);
|
||||
const delivery = await storage.getDelivery(deliveryId);
|
||||
|
||||
if (!delivery) {
|
||||
return res.status(404).json({ message: "Livraison introuvable" });
|
||||
}
|
||||
|
||||
if (!hasPermission(user.role, 'deliveries', 'view')) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
// Hors admin, l'utilisateur doit appartenir au magasin de la livraison
|
||||
if (user.role !== 'admin') {
|
||||
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
|
||||
if (!userGroupIds.includes(delivery.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this group" });
|
||||
}
|
||||
}
|
||||
|
||||
// Adresse du fournisseur : celle enregistrée sur sa fiche
|
||||
const supplierEmail = delivery.supplier?.email?.trim();
|
||||
if (!supplierEmail) {
|
||||
return res.status(400).json({
|
||||
message: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration SMTP du magasin (mot de passe inclus : usage serveur uniquement)
|
||||
const group = await storage.getGroup(delivery.groupId);
|
||||
if (!group) {
|
||||
return res.status(404).json({ message: "Magasin de la livraison introuvable" });
|
||||
}
|
||||
|
||||
if (!(group as any).smtpEnabled) {
|
||||
return res.status(400).json({
|
||||
message: `L'envoi de mails n'est pas activé pour le magasin ${group.name}. Renseignez la configuration SMTP sur sa fiche.`
|
||||
});
|
||||
}
|
||||
|
||||
const missing = getMissingSmtpFields(group as any);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
message: `Configuration SMTP incomplète pour ${group.name} : ${missing.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
|
||||
|
||||
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 (error: any) {
|
||||
console.error("Erreur envoi mail fournisseur:", error);
|
||||
res.status(500).json({
|
||||
message: error?.message || "Impossible d'envoyer le mail au fournisseur"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Nettoyage des données sensibles avant envoi au client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retire récursivement le mot de passe SMTP des réponses API et le remplace par
|
||||
* un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux
|
||||
* imbriqués car les magasins apparaissent sous plusieurs formes : liste de
|
||||
* groupes, champ "group" d'une livraison ou d'une commande, relations
|
||||
* utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une
|
||||
* seule fois à la sortie.
|
||||
*/
|
||||
export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Les structures cycliques sont renvoyées telles quelles plutôt que de
|
||||
// faire boucler la récursion
|
||||
if (seen.has(value)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => stripSmtpPassword(item, depth + 1, seen));
|
||||
}
|
||||
|
||||
// Ne pas dénaturer les types non sérialisables en objets simples
|
||||
if (value instanceof Date || Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === 'smtpPassword' || key === 'smtp_password') {
|
||||
result.smtpPasswordSet = Boolean(entry);
|
||||
continue;
|
||||
}
|
||||
result[key] = stripSmtpPassword(entry, depth + 1, seen);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -61,6 +61,19 @@ export const groups = pgTable("groups", {
|
||||
nocodbSupplierColumnName: varchar("nocodb_supplier_column_name"), // Nom de la colonne fournisseur dans leur table
|
||||
nocodbDueDateColumnName: varchar("nocodb_due_date_column_name"), // Nom de la colonne date d'échéance dans leur table
|
||||
webhookUrl: varchar("webhook_url", { length: 500 }), // URL de webhook pour notifications par magasin
|
||||
// Coordonnées du magasin, reprises dans la signature des mails fournisseurs
|
||||
address: text("address"), // Adresse postale
|
||||
phone: varchar("phone", { length: 50 }), // Téléphone
|
||||
logo: text("logo"), // Logo en data URI (data:image/png;base64,...), intégré aux mails
|
||||
// Configuration SMTP propre au magasin (envoi des mails fournisseurs)
|
||||
smtpEnabled: boolean("smtp_enabled").default(false),
|
||||
smtpHost: varchar("smtp_host", { length: 255 }),
|
||||
smtpPort: integer("smtp_port"),
|
||||
smtpSecure: boolean("smtp_secure").default(false), // true = SSL/TLS direct (465), false = STARTTLS
|
||||
smtpUser: varchar("smtp_user", { length: 255 }),
|
||||
smtpPassword: varchar("smtp_password", { length: 255 }), // jamais renvoyé au client
|
||||
smtpSenderEmail: varchar("smtp_sender_email", { length: 255 }), // adresse expéditeur "De :"
|
||||
smtpSenderName: varchar("smtp_sender_name", { length: 255 }), // nom affiché, par défaut le nom du magasin
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
@@ -554,6 +567,17 @@ export const insertGroupSchema = createInsertSchema(groups).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}).extend({
|
||||
// Champs optionnels : le formulaire envoie "" quand ils ne sont pas renseignés
|
||||
address: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
logo: z.string().optional().nullable(),
|
||||
smtpHost: z.string().optional().nullable(),
|
||||
smtpPort: z.coerce.number().int().min(1).max(65535).optional().nullable(),
|
||||
smtpUser: z.string().optional().nullable(),
|
||||
smtpPassword: z.string().optional().nullable(),
|
||||
smtpSenderEmail: z.union([z.string().email(), z.literal("")]).optional().nullable(),
|
||||
smtpSenderName: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const insertSupplierSchema = createInsertSchema(suppliers).omit({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Contenu des mails de relance fournisseur (rapprochement BL/Factures).
|
||||
* Partagé client/serveur : le serveur construit et envoie le mail via le SMTP
|
||||
* du magasin, le client peut réutiliser l'objet pour l'affichage.
|
||||
*/
|
||||
|
||||
export interface SupplierMailDelivery {
|
||||
blNumber?: string | null;
|
||||
blAmount?: string | number | null;
|
||||
invoiceReference?: string | null;
|
||||
scheduledDate?: string | Date | null;
|
||||
deliveredDate?: string | Date | null;
|
||||
supplier?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
contact?: string | null;
|
||||
} | null;
|
||||
group?: { name?: string | null } | null;
|
||||
}
|
||||
|
||||
/** Coordonnées du magasin reprises dans la signature. */
|
||||
export interface StoreSignature {
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Formatage jj/mm/aaaa, tolérant aux valeurs absentes ou invalides. */
|
||||
function formatDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return "";
|
||||
try {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
|
||||
const day = String(dateObj.getDate()).padStart(2, "0");
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}/${month}/${dateObj.getFullYear()}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Objet du mail : identifie la livraison concernée.
|
||||
*/
|
||||
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
const reference = delivery.blNumber
|
||||
? `BL n° ${delivery.blNumber}`
|
||||
: formattedDate
|
||||
? `Livraison du ${formattedDate}`
|
||||
: "Livraison";
|
||||
|
||||
const parts = [
|
||||
"Demande de facture (PDF) ou BL (Excel)",
|
||||
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
|
||||
];
|
||||
|
||||
if (delivery.group?.name) {
|
||||
parts.push(delivery.group.name);
|
||||
}
|
||||
|
||||
return parts.join(" - ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif de la livraison : seules les informations renseignées sont listées.
|
||||
*/
|
||||
function buildDeliveryDetails(delivery: SupplierMailDelivery): string[] {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
const blAmount =
|
||||
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
|
||||
? parseFloat(String(delivery.blAmount))
|
||||
: null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (delivery.supplier?.name) details.push(`Fournisseur : ${delivery.supplier.name}`);
|
||||
if (delivery.group?.name) details.push(`Magasin : ${delivery.group.name}`);
|
||||
if (formattedDate) details.push(`Date de livraison : ${formattedDate}`);
|
||||
if (delivery.blNumber) details.push(`N° de BL : ${delivery.blNumber}`);
|
||||
if (blAmount !== null && !isNaN(blAmount)) {
|
||||
details.push(`Montant du BL : ${blAmount.toFixed(2)} €`);
|
||||
}
|
||||
if (delivery.invoiceReference) {
|
||||
details.push(`Référence facture : ${delivery.invoiceReference}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : ["(informations de livraison à préciser)"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Version texte du mail (repli pour les clients sans HTML).
|
||||
*/
|
||||
export function buildSupplierMailText(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery).map(line => `- ${line}`);
|
||||
|
||||
const signature = [store?.name, store?.address, store?.phone ? `Tél. ${store.phone}` : null, store?.email]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const lines = [
|
||||
"Bonjour,",
|
||||
"",
|
||||
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||
"",
|
||||
"- la facture au format PDF, ou",
|
||||
"- le bon de livraison au format Excel",
|
||||
"",
|
||||
"Livraison concernée :",
|
||||
...details,
|
||||
"",
|
||||
"Vous en remerciant par avance.",
|
||||
"",
|
||||
"Cordialement,",
|
||||
...(signature ? [signature] : []),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Échappement HTML des valeurs injectées dans le corps du mail. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Version HTML du mail, avec la signature du magasin et son logo.
|
||||
* Le logo est référencé par `cid:logo` : il est joint au message par
|
||||
* l'expéditeur, ce qui évite les images distantes bloquées par Outlook.
|
||||
*/
|
||||
export function buildSupplierMailHtml(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null,
|
||||
options: { hasLogo?: boolean } = {}
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery)
|
||||
.map(line => `<li>${escapeHtml(line)}</li>`)
|
||||
.join("");
|
||||
|
||||
const signatureLines = [
|
||||
store?.name ? `<strong>${escapeHtml(store.name)}</strong>` : null,
|
||||
store?.address ? escapeHtml(store.address).replace(/\n/g, "<br>") : null,
|
||||
store?.phone ? `Tél. ${escapeHtml(store.phone)}` : null,
|
||||
store?.email
|
||||
? `<a href="mailto:${escapeHtml(store.email)}">${escapeHtml(store.email)}</a>`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const logoBlock = options.hasLogo
|
||||
? '<p style="margin:0 0 8px 0;"><img src="cid:logo" alt="" style="max-width:200px;height:auto;border:0;"></p>'
|
||||
: "";
|
||||
|
||||
const signatureBlock = signatureLines.length > 0 || logoBlock
|
||||
? `${logoBlock}<p style="margin:0;color:#444;font-size:13px;line-height:1.5;">${signatureLines.join("<br>")}</p>`
|
||||
: "";
|
||||
|
||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;">
|
||||
<p>Bonjour,</p>
|
||||
<p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :</p>
|
||||
<ul>
|
||||
<li>la facture au format PDF, ou</li>
|
||||
<li>le bon de livraison au format Excel</li>
|
||||
</ul>
|
||||
<p style="margin-bottom:4px;">Livraison concernée :</p>
|
||||
<ul>${details}</ul>
|
||||
<p>Vous en remerciant par avance.</p>
|
||||
<p style="margin-bottom:12px;">Cordialement,</p>
|
||||
${signatureBlock}
|
||||
</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user