diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index d247609e..84dbc2c9 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -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() {} }); diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index a9972407..6d46d769 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -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 = {