secondary email
This commit is contained in:
@@ -154,10 +154,8 @@ function renderModalContent() {
|
|||||||
<form id="email-send-form" class="space-y-5">
|
<form id="email-send-form" class="space-y-5">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recipient Email *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Send to</label>
|
||||||
<input type="email" id="email-recipient" value="${defaultEmail}" required
|
${renderRecipientSelector(currentInvoice)}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
@@ -270,6 +268,15 @@ function renderModalContent() {
|
|||||||
|
|
||||||
quillInstance.root.innerHTML = defaultHtml;
|
quillInstance.root.innerHTML = defaultHtml;
|
||||||
|
|
||||||
|
// Initiale Recipient-Summary
|
||||||
|
updateRecipientSummary();
|
||||||
|
|
||||||
|
// Listener für das Custom-Field
|
||||||
|
const customEl = document.getElementById('email-recipient-custom');
|
||||||
|
if (customEl) {
|
||||||
|
customEl.addEventListener('input', updateRecipientSummary);
|
||||||
|
}
|
||||||
|
|
||||||
// Bind Submit Handler
|
// Bind Submit Handler
|
||||||
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
||||||
}
|
}
|
||||||
@@ -365,14 +372,20 @@ export function closeEmailModal() {
|
|||||||
async function submitEmail(e) {
|
async function submitEmail(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
const recipients = getSelectedRecipients();
|
||||||
const customText = quillInstance.root.innerHTML;
|
const customText = quillInstance.root.innerHTML;
|
||||||
|
|
||||||
if (!recipientEmail) {
|
if (recipients.length === 0) {
|
||||||
alert('Please enter a recipient email.');
|
alert('Please select or enter at least one recipient email.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm bei Mehrfach-Empfängern, damit der User sieht, an wen es geht
|
||||||
|
if (recipients.length > 1) {
|
||||||
|
const ok = confirm(`Send invoice to ${recipients.length} recipients?\n\n${recipients.join('\n')}`);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
const submitBtn = document.getElementById('email-submit-btn');
|
const submitBtn = document.getElementById('email-submit-btn');
|
||||||
submitBtn.innerHTML = '⏳ Sending...';
|
submitBtn.innerHTML = '⏳ Sending...';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
@@ -384,7 +397,7 @@ async function submitEmail(e) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipientEmail,
|
recipientEmails: recipients,
|
||||||
customText
|
customText
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -392,7 +405,8 @@ async function submitEmail(e) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('✅ Invoice sent successfully!');
|
const sentTo = (result.recipients || recipients).join(', ');
|
||||||
|
alert(`✅ Invoice sent to: ${sentTo}`);
|
||||||
closeEmailModal();
|
closeEmailModal();
|
||||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
} else {
|
} else {
|
||||||
@@ -408,11 +422,134 @@ async function submitEmail(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRecipientSelector(invoice) {
|
||||||
|
const primary = (invoice.email || '').trim();
|
||||||
|
const secondary = (invoice.secondary_email || '').trim();
|
||||||
|
const hasPrimary = !!primary;
|
||||||
|
const hasSecondary = !!secondary;
|
||||||
|
|
||||||
|
// Default-Auswahl:
|
||||||
|
// - "Both" wenn beide vorhanden
|
||||||
|
// - "Primary" wenn nur primary
|
||||||
|
// - "Secondary" wenn nur secondary
|
||||||
|
// - "Custom" wenn keine
|
||||||
|
let defaultMode;
|
||||||
|
if (hasPrimary && hasSecondary) defaultMode = 'both';
|
||||||
|
else if (hasPrimary) defaultMode = 'primary';
|
||||||
|
else if (hasSecondary) defaultMode = 'secondary';
|
||||||
|
else defaultMode = 'custom';
|
||||||
|
|
||||||
|
// Falls weder primary noch secondary — direkt eine Warnung
|
||||||
|
let warningHtml = '';
|
||||||
|
if (!hasPrimary && !hasSecondary) {
|
||||||
|
warningHtml = `
|
||||||
|
<p class="text-xs text-amber-600 mt-1">
|
||||||
|
⚠️ This customer has no email address on file. Please enter one below.
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<select id="email-recipient-mode"
|
||||||
|
onchange="window.emailModal.onRecipientModeChange()"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 text-sm">
|
||||||
|
${hasPrimary ? `<option value="primary" ${defaultMode==='primary'?'selected':''}>Primary: ${escapeAttr(primary)}</option>` : ''}
|
||||||
|
${hasSecondary ? `<option value="secondary" ${defaultMode==='secondary'?'selected':''}>Secondary: ${escapeAttr(secondary)}</option>` : ''}
|
||||||
|
${hasPrimary && hasSecondary ? `<option value="both" ${defaultMode==='both'?'selected':''}>Both (Primary + Secondary)</option>` : ''}
|
||||||
|
<option value="custom" ${defaultMode==='custom'?'selected':''}>Custom / Test address…</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input type="email" id="email-recipient-custom"
|
||||||
|
value="${escapeAttr(primary || secondary || '')}"
|
||||||
|
placeholder="Enter custom recipient email(s), comma-separated"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 ${defaultMode==='custom' ? '' : 'hidden'}">
|
||||||
|
|
||||||
|
<p id="email-recipient-summary" class="text-xs text-gray-500"></p>
|
||||||
|
${warningHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die aktuell ausgewählten Empfänger als Array.
|
||||||
|
* Wird beim Submit aufgerufen.
|
||||||
|
*/
|
||||||
|
function getSelectedRecipients() {
|
||||||
|
const modeEl = document.getElementById('email-recipient-mode');
|
||||||
|
const customEl = document.getElementById('email-recipient-custom');
|
||||||
|
if (!modeEl) return [];
|
||||||
|
|
||||||
|
const mode = modeEl.value;
|
||||||
|
const primary = (currentInvoice.email || '').trim();
|
||||||
|
const secondary = (currentInvoice.secondary_email || '').trim();
|
||||||
|
|
||||||
|
if (mode === 'primary') return primary ? [primary] : [];
|
||||||
|
if (mode === 'secondary') return secondary ? [secondary] : [];
|
||||||
|
if (mode === 'both') {
|
||||||
|
return [primary, secondary].filter(Boolean);
|
||||||
|
}
|
||||||
|
if (mode === 'custom') {
|
||||||
|
return (customEl?.value || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird vom <select onchange> aufgerufen.
|
||||||
|
* Zeigt/versteckt das Custom-Eingabefeld und aktualisiert die Zusammenfassung.
|
||||||
|
*/
|
||||||
|
function onRecipientModeChange() {
|
||||||
|
const modeEl = document.getElementById('email-recipient-mode');
|
||||||
|
const customEl = document.getElementById('email-recipient-custom');
|
||||||
|
if (!modeEl) return;
|
||||||
|
|
||||||
|
const mode = modeEl.value;
|
||||||
|
|
||||||
|
// Custom-Field nur bei mode==='custom' anzeigen
|
||||||
|
if (mode === 'custom') {
|
||||||
|
customEl.classList.remove('hidden');
|
||||||
|
customEl.focus();
|
||||||
|
} else {
|
||||||
|
customEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecipientSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecipientSummary() {
|
||||||
|
const summary = document.getElementById('email-recipient-summary');
|
||||||
|
if (!summary) return;
|
||||||
|
|
||||||
|
const recipients = getSelectedRecipients();
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
summary.textContent = '⚠️ No recipients selected.';
|
||||||
|
summary.className = 'text-xs text-amber-600';
|
||||||
|
} else if (recipients.length === 1) {
|
||||||
|
summary.textContent = `Will send to: ${recipients[0]}`;
|
||||||
|
summary.className = 'text-xs text-gray-600';
|
||||||
|
} else {
|
||||||
|
summary.textContent = `Will send to ${recipients.length} recipients: ${recipients.join(', ')}`;
|
||||||
|
summary.className = 'text-xs text-gray-600';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Expose
|
// Expose
|
||||||
// ============================================================
|
// ============================================================
|
||||||
window.emailModal = {
|
window.emailModal = {
|
||||||
open: openEmailModal,
|
open: openEmailModal,
|
||||||
close: closeEmailModal,
|
close: closeEmailModal,
|
||||||
generateStripeLink
|
generateStripeLink,
|
||||||
|
onRecipientModeChange
|
||||||
};
|
};
|
||||||
@@ -248,15 +248,29 @@ export function openModal(customerId = null) {
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Primary Email</label>
|
||||||
<input type="email" id="cf-email" value="${customer?.email || ''}"
|
<input type="email" id="cf-email" value="${customer?.email || ''}"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Secondary Email</label>
|
||||||
|
<input type="email" id="cf-secondary-email" value="${customer?.secondary_email || ''}"
|
||||||
|
placeholder="Optional — used for invoices when 'Both' is selected"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
<input type="tel" id="cf-phone" value="${customer?.phone || ''}"
|
<input type="tel" id="cf-phone" value="${customer?.phone || ''}"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Phone (alt)</label>
|
||||||
|
<input type="tel" id="cf-phone2" value="${customer?.phone2 || ''}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -303,8 +317,9 @@ async function handleSubmit(e) {
|
|||||||
zip_code: document.getElementById('cf-zip').value || null,
|
zip_code: document.getElementById('cf-zip').value || null,
|
||||||
account_number: document.getElementById('cf-account').value || null,
|
account_number: document.getElementById('cf-account').value || null,
|
||||||
email: document.getElementById('cf-email').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,
|
phone: document.getElementById('cf-phone').value || null,
|
||||||
phone2: null,
|
phone2: document.getElementById('cf-phone2').value || null,
|
||||||
taxable: document.getElementById('cf-taxable').checked,
|
taxable: document.getElementById('cf-taxable').checked,
|
||||||
remarks: document.getElementById('cf-remarks').value || null
|
remarks: document.getElementById('cf-remarks').value || null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ router.get('/', async (req, res) => {
|
|||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
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;
|
} = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
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)
|
`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) RETURNING *`,
|
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,
|
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
city || null, state || null, zip_code || null, account_number || 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]
|
taxable !== undefined ? taxable : true, remarks || null]
|
||||||
);
|
);
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
@@ -46,20 +46,21 @@ router.put('/:id', async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const {
|
const {
|
||||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
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;
|
} = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE customers
|
`UPDATE customers
|
||||||
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
|
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,
|
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
|
||||||
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
|
secondary_email = $12, phone = $13, phone2 = $14, taxable = $15,
|
||||||
WHERE id = $16
|
remarks = $16, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $17
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
city || null, state || null, zip_code || null, account_number || 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]
|
taxable !== undefined ? taxable : true, remarks || null, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,7 +144,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load customer
|
|
||||||
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
if (custResult.rows.length === 0) {
|
if (custResult.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Customer not found' });
|
return res.status(404).json({ error: 'Customer not found' });
|
||||||
@@ -151,14 +151,12 @@ router.delete('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const customer = custResult.rows[0];
|
const customer = custResult.rows[0];
|
||||||
|
|
||||||
// Deactivate in QBO if present
|
|
||||||
if (customer.qbo_id) {
|
if (customer.qbo_id) {
|
||||||
try {
|
try {
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
const companyId = oauthClient.getToken().realmId;
|
const companyId = oauthClient.getToken().realmId;
|
||||||
const baseUrl = getQboBaseUrl();
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
// Get SyncToken
|
|
||||||
const qboRes = await makeQboApiCall({
|
const qboRes = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
@@ -188,7 +186,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete locally
|
|
||||||
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
|
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
||||||
@@ -222,7 +219,6 @@ router.post('/:id/export-qbo', async (req, res) => {
|
|||||||
Notes: customer.remarks || undefined
|
Notes: customer.remarks || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contact
|
|
||||||
if (customer.contact) {
|
if (customer.contact) {
|
||||||
const parts = customer.contact.trim().split(/\s+/);
|
const parts = customer.contact.trim().split(/\s+/);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
@@ -233,7 +229,6 @@ router.post('/:id/export-qbo', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address
|
|
||||||
const addr = {};
|
const addr = {};
|
||||||
if (customer.line1) addr.Line1 = customer.line1;
|
if (customer.line1) addr.Line1 = customer.line1;
|
||||||
if (customer.line2) addr.Line2 = customer.line2;
|
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;
|
||||||
@@ -932,16 +932,48 @@ router.get('/:id/html', async (req, res) => {
|
|||||||
res.status(500).json({ error: 'Error generating HTML' });
|
res.status(500).json({ error: 'Error generating HTML' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/send-email', async (req, res) => {
|
router.post('/:id/send-email', async (req, res) => {
|
||||||
const { id } = req.params;
|
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) {
|
// Normalisiere zu einem Array. Strings werden gesplittet und gefiltert.
|
||||||
return res.status(400).json({ error: 'Recipient email is required.' });
|
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 {
|
try {
|
||||||
// 1. Rechnungsdaten und Items laden (analog zu deiner PDF-Route)
|
// 1. Rechnungsdaten und Items laden
|
||||||
const invoiceResult = await pool.query(`
|
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,
|
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
|
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
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
WHERE i.id = $1
|
WHERE i.id = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
if (invoiceResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
if (invoiceResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
const invoice = invoiceResult.rows[0];
|
const invoice = invoiceResult.rows[0];
|
||||||
|
|
||||||
const itemsResult = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
|
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');
|
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
|
||||||
let html = await fs.readFile(templatePath, 'utf-8');
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
const logoHTML = await getLogoHtml();
|
const logoHTML = await getLogoHtml();
|
||||||
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
||||||
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||||||
@@ -978,30 +1010,37 @@ router.post('/:id/send-email', async (req, res) => {
|
|||||||
.replace('{{AUTHORIZATION}}', authHTML)
|
.replace('{{AUTHORIZATION}}', authHTML)
|
||||||
.replace('{{ITEMS}}', itemsHTML)
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||||||
|
|
||||||
const pdfBuffer = await generatePdfFromHtml(html);
|
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 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
|
// 4. Status in der DB aktualisieren
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE invoices
|
`UPDATE invoices
|
||||||
SET email_status = 'sent',
|
SET email_status = 'sent',
|
||||||
sent_dates = array_append(COALESCE(sent_dates, '{}'), CURRENT_DATE),
|
sent_dates = array_append(COALESCE(sent_dates, '{}'), CURRENT_DATE),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[id]
|
[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) {
|
} catch (error) {
|
||||||
console.error('Error sending invoice email:', error);
|
console.error('Error sending invoice email:', error);
|
||||||
res.status(500).json({ error: 'Failed to send email: ' + error.message });
|
res.status(500).json({ error: 'Failed to send email: ' + error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST create Stripe Payment Link
|
// POST create Stripe Payment Link
|
||||||
router.post('/:id/create-payment-link', async (req, res) => {
|
router.post('/:id/create-payment-link', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@@ -111,12 +111,18 @@ function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) {
|
|||||||
return result.html;
|
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);
|
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 = {
|
const mailOptions = {
|
||||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||||
to: recipientEmail,
|
to: toList.join(', '),
|
||||||
bcc: 'accounting@bayarea-cc.com',
|
bcc: 'accounting@bayarea-cc.com',
|
||||||
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
|
|||||||
Reference in New Issue
Block a user