From a2b2d3fb313f347bac505b5d1846dce2b679ce55 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 00:42:08 +0200
Subject: [PATCH] fix(crm): PR #603 review follow-ups + Outlook-proof email
design
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
(was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
route.
- email transporter: close the previous instance before re-init (leak guard
for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
inline the themed CTA button, so the design survives Outlook/Apple Mail
stripping the head
-
-
+
+
+
+
+
+
+
+
+
+ |
+ ${inlinedBody}
+ |
+
+
+
+
+
+ |
+
+
`;
}
@@ -886,7 +916,18 @@ async function getScheduledEmailConfig() {
const schedule = normaliseSchedule(profile.business_hours);
let timezone = (profile.timezone || '').trim();
- if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ if (!timezone) {
+ // PR #603 review follow-up #4 — business hours are configured but the
+ // profile timezone is blank, so we fall back to the SERVER's tz (usually
+ // UTC on a Docker host). That silently shifts every business-hours
+ // calculation. Warn loudly so the admin sets business_profile.timezone.
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ logger.warn(
+ 'Scheduled-email business hours are set but business_profile.timezone is blank — '
+ + `falling back to the server timezone (${timezone}). Set the profile timezone `
+ + 'so business-hours snapping uses your local time, not the server\'s.',
+ );
+ }
// Reject a bogus tz before it reaches Intl in the snap helper.
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 91a92e68..4f708097 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -694,6 +694,22 @@ async function createInvoice(payload, adminId, trx = db) {
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerCanBill(customer);
+ // PR #603 review follow-up #1 — when an invoice is attached to an event,
+ // make sure that event actually belongs to the chosen customer. Without
+ // this, a typo'd/copy-pasted eventId silently links the invoice to an
+ // unrelated event, producing misleading reporting links. Only enforced
+ // when the event HAS customer assignments (an event with none — e.g. a
+ // legacy import — is allowed through, since we can't prove a mismatch).
+ if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) {
+ const assignments = await trx('event_customer_assignments')
+ .where({ event_id: payload.eventId })
+ .select('customer_account_id');
+ if (assignments.length > 0 &&
+ !assignments.some(a => a.customer_account_id === payload.customerAccountId)) {
+ throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH');
+ }
+ }
+
// Accumulator intercept (migration 128). For customers in
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
// APPENDS line items onto a single running draft instead of minting a