From 536f0cd9d9a423ced281b4484ce6536b2c29b1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 17:03:53 +0200 Subject: [PATCH] feat: multi-destinataires email par societe --- backend/src/migrations/001_init.sql | 16 ++-- backend/src/routes/companies.ts | 25 +++--- backend/src/routes/invoices.ts | 17 +++-- backend/src/services/email.ts | 13 ++-- frontend/src/pages/Settings.tsx | 114 ++++++++++++++++++++++------ frontend/src/types/index.ts | 1 + 6 files changed, 135 insertions(+), 51 deletions(-) diff --git a/backend/src/migrations/001_init.sql b/backend/src/migrations/001_init.sql index fac6730..5829f89 100644 --- a/backend/src/migrations/001_init.sql +++ b/backend/src/migrations/001_init.sql @@ -46,14 +46,18 @@ CREATE TABLE IF NOT EXISTS app_settings ( -- Entités vers lesquelles envoyer les factures à rembourser -- ============================================================= CREATE TABLE IF NOT EXISTS companies ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, -- destinataire de l'email de remboursement - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, -- destinataire principal + extra_emails TEXT[] NOT NULL DEFAULT '{}', -- destinataires supplémentaires + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() ); +-- Migration idempotente : ajoute extra_emails si la colonne n'existe pas encore +ALTER TABLE companies ADD COLUMN IF NOT EXISTS extra_emails TEXT[] NOT NULL DEFAULT '{}'; + -- ============================================================= -- CATÉGORIES DE DÉPENSES -- ============================================================= diff --git a/backend/src/routes/companies.ts b/backend/src/routes/companies.ts index fc7f19b..88337c4 100644 --- a/backend/src/routes/companies.ts +++ b/backend/src/routes/companies.ts @@ -11,35 +11,40 @@ const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promi (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); const companySchema = z.object({ - name: z.string().min(1).max(255), - email: z.string().email(), + name: z.string().min(1).max(255), + email: z.string().email('Email principal invalide'), + extra_emails: z + .array(z.string().email('Adresse email supplémentaire invalide')) + .default([]), }); /** GET /api/companies — Liste toutes les sociétés actives */ router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise => { const result = await db.query( - 'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name' + 'SELECT id, name, email, extra_emails, is_active FROM companies WHERE is_active = TRUE ORDER BY name' ); res.json(result.rows); })); /** POST /api/companies — Crée une société */ router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, email } = req.body; + const { name, email, extra_emails } = req.body; const result = await db.query( - `INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`, - [name, email] + `INSERT INTO companies (name, email, extra_emails) VALUES ($1, $2, $3) RETURNING *`, + [name, email, extra_emails] ); res.status(201).json(result.rows[0]); })); /** PUT /api/companies/:id — Met à jour une société */ router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, email } = req.body; + const { name, email, extra_emails } = req.body; const result = await db.query( - `UPDATE companies SET name=$1, email=$2, updated_at=NOW() - WHERE id=$3 AND is_active=TRUE RETURNING *`, - [name, email, req.params.id] + `UPDATE companies + SET name=$1, email=$2, extra_emails=$3, updated_at=NOW() + WHERE id=$4 AND is_active=TRUE + RETURNING *`, + [name, email, extra_emails, req.params.id] ); if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; } res.json(result.rows[0]); diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts index 0e06e0b..70c1b24 100644 --- a/backend/src/routes/invoices.ts +++ b/backend/src/routes/invoices.ts @@ -81,10 +81,11 @@ const createSchema = z.object({ async function getInvoiceById(id: string) { const result = await db.query( `SELECT i.*, - co.name AS company_name, - co.email AS company_email, - cat.name AS category_name, - u.name AS user_name + co.name AS company_name, + co.email AS company_email, + co.extra_emails AS company_extra_emails, + cat.name AS category_name, + u.name AS user_name FROM invoices i JOIN companies co ON co.id = i.company_id JOIN categories cat ON cat.id = i.category_id @@ -327,8 +328,12 @@ router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise { * * @param senderEmail Email de l'expéditeur (user.email en base) * @param senderName Nom affiché de l'expéditeur (user.name) - * @param toEmail Destinataire (company.email) + * @param toEmails Destinataires : email principal + éventuels emails supplémentaires * @param pdfAbsPath Chemin absolu vers le PDF généré * @param pdfFilename Nom du fichier à afficher dans la pièce jointe */ export async function sendInvoiceEmail( senderEmail: string, senderName: string, - toEmail: string, + toEmails: string | string[], pdfAbsPath: string, pdfFilename: string ): Promise { @@ -86,6 +86,11 @@ export async function sendInvoiceEmail( const pdfBuffer = await fs.readFile(pdfAbsPath); const pdfBase64 = pdfBuffer.toString('base64'); + // Normalise en tableau et déduplique + const recipients = [...new Set( + (Array.isArray(toEmails) ? toEmails : [toEmails]).filter(Boolean) + )]; + const payload = { message: { subject: 'Facture à rembourser', @@ -96,9 +101,7 @@ export async function sendInvoiceEmail( from: { emailAddress: { name: senderName, address: senderEmail }, }, - toRecipients: [ - { emailAddress: { address: toEmail } }, - ], + toRecipients: recipients.map(address => ({ emailAddress: { address } })), attachments: [ { '@odata.type': '#microsoft.graph.fileAttachment', diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7ea572d..b4254c4 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -234,14 +234,20 @@ function CompaniesSection() { queryFn: () => api.get('/companies').then(r => r.data), }); - const [editing, setEditing] = useState | null>(null); + const [editing, setEditing] = useState<(Partial & { extra_emails: string[] }) | null>(null); + const [extraInput, setExtraInput] = useState(''); const [open, setOpen] = useState(false); const save = useMutation({ - mutationFn: (c: Partial) => c.id + mutationFn: (c: Partial & { extra_emails: string[] }) => c.id ? api.put(`/companies/${c.id}`, c) : api.post('/companies', c), - onSuccess: () => { toast.success('Société sauvegardée'); qc.invalidateQueries({ queryKey: ['companies'] }); setEditing(null); }, + onSuccess: () => { + toast.success('Société sauvegardée'); + qc.invalidateQueries({ queryKey: ['companies'] }); + setEditing(null); + setExtraInput(''); + }, onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'), }); @@ -250,6 +256,24 @@ function CompaniesSection() { onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); }, }); + function startEdit(c: Company) { + setEditing({ ...c, extra_emails: c.extra_emails ?? [] }); + setExtraInput(''); + } + + function addExtra() { + const v = extraInput.trim().toLowerCase(); + if (!v || !v.includes('@')) return; + if (editing && !editing.extra_emails.includes(v)) { + setEditing(p => p ? { ...p, extra_emails: [...p.extra_emails, v] } : p); + } + setExtraInput(''); + } + + function removeExtra(email: string) { + setEditing(p => p ? { ...p, extra_emails: p.extra_emails.filter(e => e !== email) } : p); + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (editing) save.mutate(editing); @@ -280,22 +304,27 @@ function CompaniesSection() {
{/* Liste */} {companies.map(c => ( -
-
-

{c.name}

-

{c.email}

+
+
+
+

{c.name}

+

{c.email} + {(c.extra_emails?.length ?? 0) > 0 && + +{c.extra_emails.length}} +

+
+ +
- -
))} @@ -306,19 +335,56 @@ function CompaniesSection() { {editing.id ? 'Modifier la société' : 'Nouvelle société'}

setEditing(p => ({ ...p, name: e.target.value }))} /> - setEditing(p => ({ ...p, email: e.target.value }))} /> -
+ value={editing.name ?? ''} onChange={e => setEditing(p => p ? { ...p, name: e.target.value } : p)} /> + + {/* Email principal */} +
+ + setEditing(p => p ? { ...p, email: e.target.value } : p)} /> +
+ + {/* Emails supplémentaires */} +
+ + {/* Chips */} + {editing.extra_emails.length > 0 && ( +
+ {editing.extra_emails.map(em => ( + + {em} + + + ))} +
+ )} +
+ setExtraInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addExtra(); } }} /> + +
+
+ +
- +
) : (
-
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f214a87..b22d594 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -10,6 +10,7 @@ export interface Company { id: number; name: string; email: string; + extra_emails: string[]; is_active: boolean; created_at: string; }