From 250b240337733362cb9a74b248ac78b6e9347bf7 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 25 Jun 2026 19:22:04 +0200
Subject: [PATCH 1/5] fix(crm): pre-event reminder passes raw event_date (fixes
"Invalid Date" in the email)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
composePayload pre-formatted event_date to DD.MM.YYYY, but emailProcessor runs
date variables through formatDate(value, language) — new Date("25.06.2026")
can't parse → the email rendered "Invalid Date". Pass the raw event_date and let
the processor localise it, matching the expiry mailer's contract. Pre-existing
in the migration-143 composePayload (dormant while the legacy pass was gated
off); surfaced once the pre_event_email flow ran.
---
.../__tests__/integration/workflowEngine.test.js | 10 ++++++++++
backend/src/services/eventReminderService.js | 14 +++++---------
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 5981efc5..d247609e 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -415,6 +415,16 @@ describe('workflow engine', () => {
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
});
+ test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
+ const { _internal } = require('../../src/services/eventReminderService');
+ const p = _internal.composePayload({
+ event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
+ recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
+ });
+ expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
+ expect(p.event_date).not.toMatch(/invalid/i);
+ });
+
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
const webhook = engine.registry.getAction('webhook');
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js
index c22c3f62..a9972407 100644
--- a/backend/src/services/eventReminderService.js
+++ b/backend/src/services/eventReminderService.js
@@ -103,18 +103,14 @@ function composePayload({ event, recipientEmail, daysBefore, businessName }) {
|| event.host_name
|| recipientEmail
|| '';
- // Event date formatted DD.MM.YYYY here for simplicity; the rendered
- // email may further re-locale via the template engine when locale-
- // aware formatters are introduced.
- const ed = event.event_date instanceof Date ? event.event_date : new Date(event.event_date);
- const day = String(ed.getUTCDate()).padStart(2, '0');
- const month = String(ed.getUTCMonth() + 1).padStart(2, '0');
- const year = ed.getUTCFullYear();
- const eventDateFormatted = `${day}.${month}.${year}`;
+ // Pass the RAW event_date — emailProcessor.processTemplate runs it through
+ // formatDate(value, recipientLanguage). Pre-formatting it (e.g. DD.MM.YYYY)
+ // makes the processor's new Date(...) reparse fail → "Invalid Date". Same
+ // contract the expiry mailer uses.
return {
customer_name: customerName,
event_name: event.event_name || `Event #${event.id}`,
- event_date: eventDateFormatted,
+ event_date: event.event_date || '',
event_type: event.event_type || '',
days_before: daysBefore,
business_name: businessName || '',
From 10559fd68e77eb00f3dd79366fcbb7880321e6b8 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 25 Jun 2026 19:50:08 +0200
Subject: [PATCH 2/5] fix(email): resolve recipient language from the queue
row's event_id, not just email_data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Language priority is event.language → customer preferred_language → app default
→ … → en, but it was keyed on email_data.eventId, which only queueEmail injects.
Direct email_queue inserts (e.g. the gallery-publish "notify customer" path) set
the event_id COLUMN but not email_data.eventId, so those mails skipped
event.language and fell through to the default — e.g. a gallery-ready mail in EN
while the same event's pre-event reminder (sent via queueEmail) was DE.
The processor now backfills emailData.eventId from the authoritative event_id
column before rendering, so every send path resolves language from the event
consistently.
---
backend/src/services/emailProcessor.js | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index 42a454aa..65be426d 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -860,10 +860,19 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
for (const email of pendingEmails) {
try {
- const emailData = typeof email.email_data === 'string'
+ const emailData = typeof email.email_data === 'string'
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
-
+
+ // Language is resolved from emailData.eventId (event.language is the top
+ // priority). queueEmail injects it, but direct email_queue inserts (e.g.
+ // the gallery-publish notification) only set the event_id COLUMN — so
+ // backfill from the authoritative column so every send path resolves the
+ // recipient language from the event consistently.
+ if (emailData.eventId == null && email.event_id != null) {
+ emailData.eventId = email.event_id;
+ }
+
const sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
From 3ccaed06a226cdc4a18399f8daa9b2d420e3684b Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 25 Jun 2026 20:01:40 +0200
Subject: [PATCH 3/5] feat(crm): pre-event reminder falls back to the assigned
customer account
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../integration/workflowEngine.test.js | 23 ++++++
backend/src/services/eventReminderService.js | 80 ++++++++++++++-----
2 files changed, 82 insertions(+), 21 deletions(-)
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 = {
From c657892bc87f44e854827cb8a65f87cc528a2f71 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:26:28 +0200
Subject: [PATCH 4/5] feat(gallery): publish notifies assigned customer
accounts via the account email
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.
The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
---
backend/src/routes/adminEvents.js | 16 +++++++++++
.../src/services/customerAccountsService.js | 1 +
.../components/admin/PublishGalleryDialog.tsx | 27 ++++++++++++++-----
frontend/src/i18n/locales/de.json | 2 ++
frontend/src/i18n/locales/en.json | 2 ++
frontend/src/pages/admin/EventDetailsPage.tsx | 1 +
6 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index a1cc3f10..4b30546d 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -1189,6 +1189,22 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
status: 'pending',
created_at: new Date()
});
+ } else {
+ // No inline email, but the gallery may be assigned to registered customer
+ // account(s). Notify them via the account "your galleries" email
+ // (customer_gallery_assigned, in the customer's own language) instead of
+ // the gallery_created mail, which needs an inline recipient. Best-effort.
+ try {
+ const customerAccountsService = require('../services/customerAccountsService');
+ const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
+ for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) {
+ await customerAccountsService
+ .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
+ .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
+ }
+ } catch (err) {
+ logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
+ }
}
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 8b8061d1..2dd76b38 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -1456,6 +1456,7 @@ module.exports = {
setAssignmentsForEvent,
setAssignmentsForCustomer,
getAssignmentsForEvent,
+ notifyCustomerOfNewAssignments,
listEventsForCustomer,
customerHasAccessToEvent,
getPendingInvitations,
diff --git a/frontend/src/components/admin/PublishGalleryDialog.tsx b/frontend/src/components/admin/PublishGalleryDialog.tsx
index d0421602..5aa09c29 100644
--- a/frontend/src/components/admin/PublishGalleryDialog.tsx
+++ b/frontend/src/components/admin/PublishGalleryDialog.tsx
@@ -7,6 +7,8 @@ interface PublishGalleryDialogProps {
eventName: string;
requirePassword: boolean;
customerEmail?: string | null;
+ /** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
+ assignedCustomerCount?: number;
isPublishing: boolean;
onConfirm: (password?: string) => void;
onClose: () => void;
@@ -28,11 +30,15 @@ export const PublishGalleryDialog: React.FC