feat(crm): pre-event reminder falls back to the assigned customer account
When an event has no inline customer_email/host_email but has customer account(s) assigned (event_customer_assignments), the pre-event reminder now sends to those registered customers instead of skipping with no_recipient. Recipients sent to an assigned account are queued WITHOUT eventId so the language resolver uses the customer's preferred_language (vs the event's language for inline-email sends). Applies to both the flow path (sendReminderForEvent) and the legacy pass. The gallery-ready mail deliberately does NOT fall back to accounts — only the reminder does. Test covers the no-inline-email + assigned-customer case.
This commit is contained in:
@@ -460,6 +460,29 @@ describe('workflow engine', () => {
|
||||
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
|
||||
});
|
||||
|
||||
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
|
||||
const eventReminderService = require('../../src/services/eventReminderService');
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [custId] = await db('customer_accounts').insert({
|
||||
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
|
||||
});
|
||||
// Event with NO inline customer_email / host_email.
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
|
||||
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
|
||||
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||
});
|
||||
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
|
||||
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
|
||||
|
||||
const res = await eventReminderService.sendReminderForEvent(ev.id);
|
||||
expect(res.sent).toBe(1);
|
||||
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
|
||||
expect(mail).toBeTruthy();
|
||||
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
|
||||
expect(mail.event_id == null).toBe(true);
|
||||
});
|
||||
|
||||
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||
|
||||
@@ -187,8 +187,9 @@ async function runEventReminderPass() {
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const recipientEmail = row.customer_email || row.host_email;
|
||||
if (!recipientEmail) { skipped += 1; continue; }
|
||||
// Inline event email, else the assigned customer account(s).
|
||||
const recipients = await resolveReminderRecipients(row);
|
||||
if (!recipients.length) { skipped += 1; continue; }
|
||||
const rawOffset = row.event_reminder_offset_days;
|
||||
const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
|
||||
? Number(rawOffset)
|
||||
@@ -199,21 +200,18 @@ async function runEventReminderPass() {
|
||||
if (now < triggerAt) { skipped += 1; continue; }
|
||||
|
||||
const templateKey = await resolveTemplateKey(row.event_type);
|
||||
const payload = composePayload({
|
||||
event: row, recipientEmail, daysBefore: offsetDays, businessName,
|
||||
});
|
||||
// Per-event body override: when present, append as a synthetic
|
||||
// `body_override` field. The template engine should branch on it
|
||||
// (e.g. Handlebars `{{#if body_override}}{{body_override}}{{else}}…default body…{{/if}}`).
|
||||
// For installs where the templates don't yet handle the branch,
|
||||
// the override still rides through as a variable the admin can
|
||||
// reference manually.
|
||||
if (row.event_reminder_body_override) {
|
||||
payload.body_override = row.event_reminder_body_override;
|
||||
for (const r of recipients) {
|
||||
const payload = composePayload({
|
||||
event: row, recipientEmail: r.email, daysBefore: offsetDays, businessName,
|
||||
});
|
||||
// Per-event body override rides through as a variable the template can branch on.
|
||||
if (row.event_reminder_body_override) {
|
||||
payload.body_override = row.event_reminder_body_override;
|
||||
}
|
||||
// Inline → event language; assigned account → customer's preferred language (no eventId).
|
||||
await emailProcessor.queueEmail(r.fromEvent ? row.id : null, r.email, templateKey, payload);
|
||||
}
|
||||
|
||||
await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
|
||||
|
||||
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
|
||||
// crash between queueEmail and the update) doesn't double-send
|
||||
// on the next tick. The queueEmail call is itself idempotent at
|
||||
@@ -279,8 +277,17 @@ async function sendReminderForEvent(eventId, { templateGroup = null } = {}) {
|
||||
if (row.is_active === false || row.is_active === 0 || row.is_archived === true || row.is_archived === 1) {
|
||||
return { sent: 0, skipped: 1, reason: 'inactive' };
|
||||
}
|
||||
const recipientEmail = row.customer_email || row.host_email;
|
||||
if (!recipientEmail) return { sent: 0, skipped: 1, reason: 'no_recipient' };
|
||||
|
||||
// Recipient resolution:
|
||||
// - inline event email (customer_email / host_email) → send there, language
|
||||
// follows the event (eventId passed);
|
||||
// - else fall back to the assigned customer account(s) (event_customer_assignments)
|
||||
// → send to each registered customer, honouring THEIR preferred_language
|
||||
// (queued without eventId so the resolver uses the customer, not the event).
|
||||
// The gallery-ready mail deliberately doesn't fall back to accounts, but a
|
||||
// pre-event reminder should still reach an assigned customer.
|
||||
const recipients = await resolveReminderRecipients(row);
|
||||
if (!recipients.length) return { sent: 0, skipped: 1, reason: 'no_recipient' };
|
||||
|
||||
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
|
||||
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
|
||||
@@ -295,12 +302,43 @@ async function sendReminderForEvent(eventId, { templateGroup = null } = {}) {
|
||||
// The flow block chooses the template GROUP (blank → the default group); the
|
||||
// exact template is still auto-picked by event type within that group.
|
||||
const templateKey = await resolveTemplateKey(row.event_type, templateGroup || DEFAULT_TEMPLATE_GROUP);
|
||||
const payload = composePayload({ event: row, recipientEmail, daysBefore: offsetDays, businessName });
|
||||
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
|
||||
|
||||
await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
|
||||
let sent = 0;
|
||||
for (const r of recipients) {
|
||||
const payload = composePayload({ event: row, recipientEmail: r.email, daysBefore: offsetDays, businessName });
|
||||
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
|
||||
// Inline → pass eventId (event language). Assigned account → no eventId so
|
||||
// the resolver picks the customer's preferred_language.
|
||||
await emailProcessor.queueEmail(r.fromEvent ? row.id : null, r.email, templateKey, payload);
|
||||
sent += 1;
|
||||
}
|
||||
await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
|
||||
return { sent: 1, skipped: 0, offsetDays };
|
||||
return { sent, skipped: 0, offsetDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who receives the pre-event reminder for an event: the inline event email if
|
||||
* present, otherwise the active assigned customer account(s). `fromEvent` flags
|
||||
* which language path to use (event vs customer).
|
||||
*/
|
||||
async function resolveReminderRecipients(eventRow) {
|
||||
const inline = eventRow.customer_email || eventRow.host_email;
|
||||
if (inline) return [{ email: inline, fromEvent: true }];
|
||||
|
||||
const assigned = await db('event_customer_assignments as a')
|
||||
.join('customer_accounts as c', 'c.id', 'a.customer_account_id')
|
||||
.where('a.event_id', eventRow.id)
|
||||
.where('c.is_active', true)
|
||||
.whereNotNull('c.email')
|
||||
.select('c.email');
|
||||
// De-dup emails defensively (a customer assigned twice, etc.).
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const a of assigned) {
|
||||
const e = String(a.email).toLowerCase();
|
||||
if (!seen.has(e)) { seen.add(e); out.push({ email: a.email, fromEvent: false }); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user