@@ -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,