feat(email): hold relationship mail to business hours
queueEmail gains options.respectBusinessHours: snaps the send time to the next open business-hours block (from now), only deferring when it actually falls outside hours. Applied to dunning reminders + gallery-expiry warnings; transactional/admin-initiated mail stays immediate. No-op until business hours are configured.
This commit is contained in:
@@ -900,6 +900,14 @@ async function getScheduledEmailConfig() {
|
|||||||
// options.scheduledAt — Date | ISO string; row only picks up once
|
// options.scheduledAt — Date | ISO string; row only picks up once
|
||||||
// this moment has passed (used by CRM split-
|
// this moment has passed (used by CRM split-
|
||||||
// payment invoices). NULL = send immediately.
|
// payment invoices). NULL = send immediately.
|
||||||
|
// options.respectBusinessHours — when true, snap the send time to the
|
||||||
|
// next open business-hours block (from "now").
|
||||||
|
// Use for automated/relationship mail (dunning
|
||||||
|
// reminders, gallery-expiry warnings) so we don't
|
||||||
|
// ping customers overnight. No-op when the floor
|
||||||
|
// is off / business hours unconfigured / already
|
||||||
|
// inside a block. Leave it off for transactional
|
||||||
|
// + admin-initiated mail so those send instantly.
|
||||||
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
|
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
|
||||||
// so callers don't need a new signature for every email shape.
|
// so callers don't need a new signature for every email shape.
|
||||||
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
|
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
|
||||||
@@ -916,22 +924,33 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
|||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
};
|
};
|
||||||
let snappedFrom = null;
|
let snappedFrom = null;
|
||||||
if (options.scheduledAt) {
|
// Base time to schedule from:
|
||||||
const requested = options.scheduledAt instanceof Date
|
// - explicit options.scheduledAt (CRM split-payment invoices), OR
|
||||||
? options.scheduledAt
|
// - "now" when the caller opts into the business-hours floor via
|
||||||
: new Date(options.scheduledAt);
|
// options.respectBusinessHours — automated / relationship mail
|
||||||
// Floor to the configured business-hours window so a "send in N
|
// like dunning reminders + gallery-expiry warnings, so we don't
|
||||||
// days" click at 02:11 doesn't deliver at 02:11. No-op when the
|
// ping the customer at 02:00.
|
||||||
// floor is disabled or the instant already lands inside the window.
|
// Both snap to the next open business-hours block. No-op when the
|
||||||
|
// floor is disabled, business hours are unconfigured, or the instant
|
||||||
|
// already lands inside a block. Transactional / admin-initiated mail
|
||||||
|
// (invoice_sent, storno, invitations, password resets) passes neither
|
||||||
|
// option and sends immediately.
|
||||||
|
const baseTime = options.scheduledAt
|
||||||
|
? (options.scheduledAt instanceof Date ? options.scheduledAt : new Date(options.scheduledAt))
|
||||||
|
: (options.respectBusinessHours ? new Date() : null);
|
||||||
|
if (baseTime) {
|
||||||
const cfg = await getScheduledEmailConfig();
|
const cfg = await getScheduledEmailConfig();
|
||||||
const snapped = snapToBusinessHours(requested, cfg);
|
const snapped = snapToBusinessHours(baseTime, cfg);
|
||||||
if (snapped.getTime() !== requested.getTime()) snappedFrom = requested;
|
if (snapped.getTime() !== baseTime.getTime()) snappedFrom = baseTime;
|
||||||
row.scheduled_at = snapped;
|
// Persist a future scheduled_at for an explicit scheduledAt always;
|
||||||
|
// for the respectBusinessHours floor only when it actually moved the
|
||||||
|
// time forward (inside hours → leave null → processor sends at once).
|
||||||
|
if (options.scheduledAt || snappedFrom) row.scheduled_at = snapped;
|
||||||
}
|
}
|
||||||
await db('email_queue').insert(row);
|
await db('email_queue').insert(row);
|
||||||
|
|
||||||
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
||||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()}${
|
row.scheduled_at ? ` (scheduled ${row.scheduled_at.toISOString()}${
|
||||||
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
|
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
|
||||||
})` : ''
|
})` : ''
|
||||||
}`);
|
}`);
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ async function queueExpirationWarning(event) {
|
|||||||
expiry_date: event.expires_at,
|
expiry_date: event.expires_at,
|
||||||
gallery_link: shareUrl,
|
gallery_link: shareUrl,
|
||||||
gallery_password: '{{password_security_message}}'
|
gallery_password: '{{password_security_message}}'
|
||||||
});
|
// Relationship mail — hold to business hours (no-op unless configured).
|
||||||
|
}, { respectBusinessHours: true });
|
||||||
|
|
||||||
logger.info(`Queued expiration warning for event ${event.slug}`);
|
logger.info(`Queued expiration warning for event ${event.slug}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2650,7 +2650,9 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
|||||||
contentPath: pdfPath,
|
contentPath: pdfPath,
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
}],
|
}],
|
||||||
});
|
// Dunning reminders are relationship mail — hold to business hours so
|
||||||
|
// the customer isn't pinged overnight (no-op unless hours configured).
|
||||||
|
}, { respectBusinessHours: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
|
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
|
||||||
|
|||||||
Reference in New Issue
Block a user