feat(customers): email customer when admin adds new gallery access

This commit is contained in:
Luca
2026-05-12 01:25:12 +02:00
parent 3f4419356a
commit c02c947463
3 changed files with 368 additions and 4 deletions
+117 -1
View File
@@ -671,6 +671,11 @@ async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, tr
.del();
}
// Collect the event IDs that actually landed in the DB (i.e. survived
// the archived/missing filter) so the post-commit notifier knows
// exactly which galleries to mention in the email. Empty by default.
let addedEventIds = [];
if (toAdd.length > 0) {
// Validate the events exist + are not archived before inserting.
// Mirrors the customer-side check in setAssignmentsForEvent so an
@@ -695,10 +700,121 @@ async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, tr
}));
if (rows.length > 0) {
await trx('event_customer_assignments').insert(rows);
addedEventIds = rows.map((r) => r.event_id);
}
}
return { added: toAdd.length, removed: toRemove.length };
// Notify the customer about newly-accessible galleries. Best-effort
// — a failure here must not roll back the assignment write, so we
// fire-and-forget after the transactional work is done and swallow
// any throw with a warn log. Skipped when no new rows were added.
if (addedEventIds.length > 0) {
notifyCustomerOfNewAssignments(customerId, addedEventIds).catch((err) => {
logger.warn('Failed to queue customer_gallery_assigned email', {
customerId, addedEventIds, error: err?.message,
});
});
}
return { added: toAdd.length, removed: toRemove.length, addedEventIds };
}
/**
* Queue a `customer_gallery_assigned` email summarising newly-granted
* gallery access for one customer. Called by setAssignmentsForCustomer
* after the transaction commits.
*
* Rules:
* - One email per save (digest), not one per gallery.
* - Archived + expired events are filtered out — the customer would
* hit a "this gallery has expired" notice anyway, so naming them
* in the email just confuses people.
* - Deactivated customers (is_active=false) get no email — their
* login is off, so a "you have new access" message would be
* misleading.
* - Customers without an email on file are skipped (silently —
* should never happen for accepted accounts but defensive).
* - Email failures are logged but never bubble up; the caller's
* `.catch` handler logs again at a more specific call site.
*/
async function notifyCustomerOfNewAssignments(customerId, addedEventIds) {
if (!addedEventIds || addedEventIds.length === 0) return;
const customer = await db('customer_accounts')
.where({ id: customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'preferred_language')
.first();
if (!customer || !customer.email) {
logger.info('Skip customer_gallery_assigned email: customer missing/inactive/no email', {
customerId,
});
return;
}
// Filter the added events to those the customer can actually open.
// Archived events are hard-skipped; expired ones (expires_at in the
// past) would render as "Expired DD MMM" in the dashboard and lead
// to a confusing "I clicked the link in the email and got a 410"
// experience — drop those too.
const now = new Date();
const events = await db('events')
.whereIn('id', addedEventIds)
.where('is_archived', formatBoolean(false))
.andWhere(function() {
this.whereNull('expires_at').orWhere('expires_at', '>', now);
})
.orderBy('event_date', 'desc')
.select('id', 'slug', 'event_name', 'event_date');
if (events.length === 0) {
logger.info('Skip customer_gallery_assigned email: all added events archived/expired', {
customerId, addedEventIds,
});
return;
}
// Build the gallery list block. HTML is whitelisted via
// HTML_PASSTHROUGH_KEYS in emailProcessor so the <ul> survives the
// body-html escaping pass. Names + dates come from admin-controlled
// DB rows; the date is server-rendered.
const { formatDate } = require('../utils/dateFormatter');
const { escapeHtml } = require('../utils/formatters');
const language = customer.preferred_language || 'en';
const formattedRows = await Promise.all(events.map(async (ev) => ({
name: ev.event_name || ev.slug,
date: ev.event_date ? await formatDate(ev.event_date, language) : '',
})));
const galleryListHtml = `<ul>\n${
formattedRows.map((r) => {
const safeName = escapeHtml(r.name);
const safeDate = r.date ? `${escapeHtml(r.date)}` : '';
return ` <li>${safeName}${safeDate}</li>`;
}).join('\n')
}\n</ul>`;
const galleryListText = formattedRows
.map((r) => r.date ? `- ${r.name} (${r.date})` : `- ${r.name}`)
.join('\n');
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
const customerName = customer.display_name?.trim()
|| customer.first_name?.trim()
|| (customer.email ? customer.email.split('@')[0] : '');
await queueEmail(null, customer.email, 'customer_gallery_assigned', {
customer_name: customerName,
gallery_count: String(events.length),
// `singular` / `multiple` drive the {{#if}} blocks in the
// template — safeTemplateReplace treats anything non-empty +
// non-false as truthy, so passing literal 'true' / '' works.
singular: events.length === 1 ? 'true' : '',
multiple: events.length > 1 ? 'true' : '',
gallery_list_html: galleryListHtml,
gallery_list_text: galleryListText,
dashboard_link: `${frontendUrl}/customer/dashboard`,
});
}
/**
+8 -3
View File
@@ -388,9 +388,14 @@ function htmlToText(html) {
// else (event_name, host_name, customer_name, …) is admin-supplied free
// text and gets escaped to prevent stored-HTML injection in customer mail.
const HTML_PASSTHROUGH_KEYS = new Set([
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
'gallery_link', // server-generated URL (adminEvents.js)
'client_link', // server-generated URL (adminEvents.js)
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
'gallery_link', // server-generated URL (adminEvents.js)
'client_link', // server-generated URL (adminEvents.js)
// customer_gallery_assigned template (#354 follow-up): server-rendered
// <ul> of newly-added galleries. Built in customerAccountsService from
// trusted DB rows (event_name comes from admin-owned events; the date
// is server-rendered) — escaping it here would double-escape the markup.
'gallery_list_html',
]);
const { escapeHtml } = require('../utils/formatters');