diff --git a/public/js/modals/email-modal.js b/public/js/modals/email-modal.js index 3bcfb2b..08038bf 100644 --- a/public/js/modals/email-modal.js +++ b/public/js/modals/email-modal.js @@ -154,10 +154,8 @@ function renderModalContent() {
- - -

You can override this for testing.

+ + ${renderRecipientSelector(currentInvoice)}
+
+ + +
+
+ +
+
+ + +
@@ -303,8 +317,9 @@ async function handleSubmit(e) { zip_code: document.getElementById('cf-zip').value || null, account_number: document.getElementById('cf-account').value || null, email: document.getElementById('cf-email').value || null, + secondary_email: document.getElementById('cf-secondary-email').value || null, phone: document.getElementById('cf-phone').value || null, - phone2: null, + phone2: document.getElementById('cf-phone2').value || null, taxable: document.getElementById('cf-taxable').checked, remarks: document.getElementById('cf-remarks').value || null }; diff --git a/src/routes/customers.js b/src/routes/customers.js index 24e8b6b..573741a 100644 --- a/src/routes/customers.js +++ b/src/routes/customers.js @@ -22,16 +22,16 @@ router.get('/', async (req, res) => { router.post('/', async (req, res) => { const { name, contact, line1, line2, line3, line4, city, state, zip_code, - account_number, email, phone, phone2, taxable, remarks + account_number, email, secondary_email, phone, phone2, taxable, remarks } = req.body; try { const result = await pool.query( - `INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`, + `INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, secondary_email, phone, phone2, taxable, remarks) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *`, [name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null, city || null, state || null, zip_code || null, account_number || null, - email || null, phone || null, phone2 || null, + email || null, secondary_email || null, phone || null, phone2 || null, taxable !== undefined ? taxable : true, remarks || null] ); res.json(result.rows[0]); @@ -46,20 +46,21 @@ router.put('/:id', async (req, res) => { const { id } = req.params; const { name, contact, line1, line2, line3, line4, city, state, zip_code, - account_number, email, phone, phone2, taxable, remarks + account_number, email, secondary_email, phone, phone2, taxable, remarks } = req.body; try { const result = await pool.query( - `UPDATE customers - SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6, - city = $7, state = $8, zip_code = $9, account_number = $10, email = $11, - phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP - WHERE id = $16 + `UPDATE customers + SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6, + city = $7, state = $8, zip_code = $9, account_number = $10, email = $11, + secondary_email = $12, phone = $13, phone2 = $14, taxable = $15, + remarks = $16, updated_at = CURRENT_TIMESTAMP + WHERE id = $17 RETURNING *`, [name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null, city || null, state || null, zip_code || null, account_number || null, - email || null, phone || null, phone2 || null, + email || null, secondary_email || null, phone || null, phone2 || null, taxable !== undefined ? taxable : true, remarks || null, id] ); @@ -143,7 +144,6 @@ router.delete('/:id', async (req, res) => { const { id } = req.params; try { - // Load customer const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); if (custResult.rows.length === 0) { return res.status(404).json({ error: 'Customer not found' }); @@ -151,14 +151,12 @@ router.delete('/:id', async (req, res) => { const customer = custResult.rows[0]; - // Deactivate in QBO if present if (customer.qbo_id) { try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); - // Get SyncToken const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`, method: 'GET' @@ -188,7 +186,6 @@ router.delete('/:id', async (req, res) => { } } - // Delete locally await pool.query('DELETE FROM customers WHERE id = $1', [id]); res.json({ success: true }); @@ -222,7 +219,6 @@ router.post('/:id/export-qbo', async (req, res) => { Notes: customer.remarks || undefined }; - // Contact if (customer.contact) { const parts = customer.contact.trim().split(/\s+/); if (parts.length >= 2) { @@ -233,7 +229,6 @@ router.post('/:id/export-qbo', async (req, res) => { } } - // Address const addr = {}; if (customer.line1) addr.Line1 = customer.line1; if (customer.line2) addr.Line2 = customer.line2; @@ -267,4 +262,4 @@ router.post('/:id/export-qbo', async (req, res) => { } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 3d3f5ee..da3a619 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -932,16 +932,48 @@ router.get('/:id/html', async (req, res) => { res.status(500).json({ error: 'Error generating HTML' }); } }); + router.post('/:id/send-email', async (req, res) => { const { id } = req.params; - const { recipientEmail, customText } = req.body; + // Akzeptiert entweder recipientEmail (Legacy, String) oder recipientEmails (Array). + const { recipientEmail, recipientEmails, customText } = req.body; - if (!recipientEmail) { - return res.status(400).json({ error: 'Recipient email is required.' }); + // Normalisiere zu einem Array. Strings werden gesplittet und gefiltert. + let recipients = []; + if (Array.isArray(recipientEmails)) { + recipients = recipientEmails; + } else if (recipientEmail) { + recipients = [recipientEmail]; + } + + recipients = recipients + .map(e => (e || '').trim()) + .filter(Boolean); + + // Dedupe, case-insensitive + const seen = new Set(); + recipients = recipients.filter(e => { + const key = e.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + if (recipients.length === 0) { + return res.status(400).json({ error: 'At least one recipient email is required.' }); + } + + // Einfache E-Mail-Validierung pro Empfänger + const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalid = recipients.filter(e => !emailRe.test(e)); + if (invalid.length > 0) { + return res.status(400).json({ + error: `Invalid email address(es): ${invalid.join(', ')}` + }); } try { - // 1. Rechnungsdaten und Items laden (analog zu deiner PDF-Route) + // 1. Rechnungsdaten und Items laden const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid @@ -949,16 +981,16 @@ router.post('/:id/send-email', async (req, res) => { LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); - + if (invoiceResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); const invoice = invoiceResult.rows[0]; const itemsResult = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]); - // 2. PDF generieren, aber nur im Speicher halten + // 2. PDF generieren const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); - + const logoHTML = await getLogoHtml(); const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice); const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; @@ -978,30 +1010,37 @@ router.post('/:id/send-email', async (req, res) => { .replace('{{AUTHORIZATION}}', authHTML) .replace('{{ITEMS}}', itemsHTML) .replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice)); - + const pdfBuffer = await generatePdfFromHtml(html); - // 3. E-Mail über SES versenden + // 3. E-Mail über SES versenden — alle Empfänger im To-Feld const stripeLink = invoice.stripe_payment_link_url || null; - const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer); + const info = await sendInvoiceEmail(invoice, recipients, customText, stripeLink, pdfBuffer); // 4. Status in der DB aktualisieren await pool.query( - `UPDATE invoices - SET email_status = 'sent', + `UPDATE invoices + SET email_status = 'sent', sent_dates = array_append(COALESCE(sent_dates, '{}'), CURRENT_DATE), - updated_at = CURRENT_TIMESTAMP + updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id] ); - res.json({ success: true, messageId: info.messageId }); + console.log(`✉️ Invoice #${invoice.invoice_number} sent to: ${recipients.join(', ')}`); + + res.json({ + success: true, + messageId: info.messageId, + recipients + }); } catch (error) { console.error('Error sending invoice email:', error); res.status(500).json({ error: 'Failed to send email: ' + error.message }); } }); + // POST create Stripe Payment Link router.post('/:id/create-payment-link', async (req, res) => { const { id } = req.params; diff --git a/src/services/email-service.js b/src/services/email-service.js index de09160..c52292d 100644 --- a/src/services/email-service.js +++ b/src/services/email-service.js @@ -111,12 +111,18 @@ function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) { return result.html; } -async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) { +async function sendInvoiceEmail(invoice, recipients, customText, stripePaymentUrl, pdfBuffer) { const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl); + // Akzeptiert String oder Array. nodemailer akzeptiert beides direkt im "to"-Feld, + // aber wir normalisieren für Konsistenz und einfacheres Logging. + const toList = Array.isArray(recipients) + ? recipients + : [recipients].filter(Boolean); + const mailOptions = { from: '"Bay Area Affiliates Inc. Accounting" ', - to: recipientEmail, + to: toList.join(', '), bcc: 'accounting@bayarea-cc.com', subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`, html: htmlContent,