diff --git a/backend/migrations/core/101_add_customer_gallery_assigned_template.js b/backend/migrations/core/101_add_customer_gallery_assigned_template.js
new file mode 100644
index 00000000..557c400b
--- /dev/null
+++ b/backend/migrations/core/101_add_customer_gallery_assigned_template.js
@@ -0,0 +1,243 @@
+/**
+ * Migration: Add `customer_gallery_assigned` email template.
+ *
+ * Sent when an admin adds new gallery assignments to an existing
+ * customer via the "Manage galleries" dialog on the customer detail
+ * page. Digest-style — one email per save listing every newly added
+ * gallery, not one email per gallery (admins often set up new clients
+ * by adding several galleries in a single sitting).
+ *
+ * Variables:
+ * - customer_name greeting name (display name / first name / email local)
+ * - gallery_count integer (string) — number of newly added galleries
+ * - singular "true" when count === 1 (drives intro wording)
+ * - multiple "true" when count > 1
+ * - gallery_list_html pre-rendered
with names + dates; passes
+ * through unescaped because the service builds it
+ * from trusted DB fields (event_name from
+ * admin-owned rows + server-rendered dates).
+ * - gallery_list_text newline-separated plain-text equivalent for
+ * the text/plain body.
+ * - dashboard_link URL of /customer/dashboard on the configured
+ * frontend origin.
+ *
+ * Category + flag: 'customers' + customerPortal — categorisation
+ * scaffold from migration 098. When the customer portal flag is off,
+ * the Templates admin UI chips this card "Feature off" but it's still
+ * editable.
+ *
+ * Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
+ * and flagged for native review per project convention.
+ *
+ * Idempotent: skips if the template_key already exists.
+ */
+
+const TRANSLATIONS = {
+ en: {
+ subject: 'New gallery access on your account',
+ body_html: `You have new gallery access
+Hi {{customer_name}},
+{{#if singular}}Your photographer just gave you access to a new gallery on your account:
{{/if}}{{#if multiple}}Your photographer just gave you access to {{gallery_count}} new galleries on your account:
{{/if}}
+{{gallery_list_html}}
+
+ Open your dashboard
+
+If the button doesn't work, copy and paste this link into your browser:
+{{dashboard_link}}
`,
+ body_text: `You have new gallery access
+
+Hi {{customer_name}},
+
+Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
+
+{{gallery_list_text}}
+
+Open your dashboard: {{dashboard_link}}`,
+ },
+ de: {
+ subject: 'Neue Galerie in deinem Konto verfügbar',
+ body_html: `Du hast Zugriff auf neue Galerien
+Hallo {{customer_name}},
+{{#if singular}}Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:
{{/if}}{{#if multiple}}Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:
{{/if}}
+{{gallery_list_html}}
+
+ Zum Dashboard
+
+Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+{{dashboard_link}}
`,
+ body_text: `Du hast Zugriff auf neue Galerien
+
+Hallo {{customer_name}},
+
+Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerie(n) in deinem Konto gegeben:
+
+{{gallery_list_text}}
+
+Zum Dashboard: {{dashboard_link}}`,
+ },
+ fr: {
+ subject: 'Nouvel accès galerie sur votre compte',
+ body_html: `Vous avez accès à de nouvelles galeries
+Bonjour {{customer_name}},
+{{#if singular}}Votre photographe vient de vous donner accès à une nouvelle galerie sur votre compte :
{{/if}}{{#if multiple}}Votre photographe vient de vous donner accès à {{gallery_count}} nouvelles galeries sur votre compte :
{{/if}}
+{{gallery_list_html}}
+
+ Ouvrir mon tableau de bord
+
+Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
+{{dashboard_link}}
`,
+ body_text: `Vous avez accès à de nouvelles galeries
+
+Bonjour {{customer_name}},
+
+Votre photographe vient de vous donner accès à {{gallery_count}} nouvelle(s) galerie(s) sur votre compte :
+
+{{gallery_list_text}}
+
+Tableau de bord : {{dashboard_link}}`,
+ },
+ nl: {
+ subject: 'Nieuwe galerij toegevoegd aan uw account',
+ body_html: `U heeft toegang tot nieuwe galerijen
+Hallo {{customer_name}},
+{{#if singular}}Uw fotograaf heeft u zojuist toegang gegeven tot een nieuwe galerij in uw account:
{{/if}}{{#if multiple}}Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerijen in uw account:
{{/if}}
+{{gallery_list_html}}
+
+ Open uw dashboard
+
+Werkt de knop niet? Kopieer dan deze link in uw browser:
+{{dashboard_link}}
`,
+ body_text: `U heeft toegang tot nieuwe galerijen
+
+Hallo {{customer_name}},
+
+Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
+
+{{gallery_list_text}}
+
+Dashboard: {{dashboard_link}}`,
+ },
+ pt: {
+ subject: 'Nova galeria disponível em sua conta',
+ body_html: `Você tem acesso a novas galerias
+Olá {{customer_name}},
+{{#if singular}}Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:
{{/if}}{{#if multiple}}Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:
{{/if}}
+{{gallery_list_html}}
+
+ Abrir meu painel
+
+Se o botão não funcionar, copie este link no navegador:
+{{dashboard_link}}
`,
+ body_text: `Você tem acesso a novas galerias
+
+Olá {{customer_name}},
+
+Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
+
+{{gallery_list_text}}
+
+Painel: {{dashboard_link}}`,
+ },
+ ru: {
+ subject: 'Новая галерея доступна в вашем аккаунте',
+ body_html: `У вас новый доступ к галереям
+Здравствуйте, {{customer_name}}!
+{{#if singular}}Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:
{{/if}}{{#if multiple}}Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
{{/if}}
+{{gallery_list_html}}
+
+ Открыть мой кабинет
+
+Если кнопка не работает, скопируйте эту ссылку в браузер:
+{{dashboard_link}}
`,
+ body_text: `У вас новый доступ к галереям
+
+Здравствуйте, {{customer_name}}!
+
+Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
+
+{{gallery_list_text}}
+
+Личный кабинет: {{dashboard_link}}`,
+ },
+};
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+
+ const existing = await knex('email_templates')
+ .where({ template_key: 'customer_gallery_assigned' })
+ .first();
+ if (existing) {
+ console.log(' customer_gallery_assigned template already exists, skipping insert');
+ return;
+ }
+
+ // Detect schema variant (legacy per-column vs normalized translations).
+ // Newer installs have the email_template_translations table from
+ // migration 075; older ones might still have subject_en/de/... columns
+ // and NOT NULL constraints on the legacy columns. Cover both.
+ const cols = await knex('email_templates').columnInfo();
+ const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
+
+ const enContent = TRANSLATIONS.en;
+
+ // Build the master row. category/subcategory/feature_flag columns
+ // were added in migration 098 — guard so this migration works on
+ // a slightly older install too.
+ const masterRow = {
+ template_key: 'customer_gallery_assigned',
+ variables: JSON.stringify([
+ 'customer_name',
+ 'gallery_count',
+ 'singular',
+ 'multiple',
+ 'gallery_list_html',
+ 'gallery_list_text',
+ 'dashboard_link',
+ ]),
+ };
+ if ('category' in cols) masterRow.category = 'customers';
+ if ('subcategory' in cols) masterRow.subcategory = null;
+ if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
+ if ('created_at' in cols) masterRow.created_at = new Date();
+ if ('updated_at' in cols) masterRow.updated_at = new Date();
+
+ // Populate any legacy subject_*/body_html_*/body_text_* columns the
+ // schema still carries. Fallback content for non-en locales is the
+ // English string — the translations table below has the real
+ // per-locale copy. This only matters if the install hasn't run
+ // migration 075 yet, which is rare but possible.
+ for (const colName of Object.keys(cols)) {
+ if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.subject;
+ } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.body_html;
+ } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.body_text;
+ }
+ }
+
+ const inserted = await knex('email_templates').insert(masterRow).returning('id');
+ const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+
+ if (hasTranslationsTable && templateId) {
+ for (const [language, content] of Object.entries(TRANSLATIONS)) {
+ await knex('email_template_translations').insert({
+ template_id: templateId,
+ language,
+ subject: content.subject,
+ body_html: content.body_html,
+ body_text: content.body_text,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ }
+ }
+
+ console.log(' customer_gallery_assigned template inserted with 6 translations');
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+ await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
+};
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 9511d303..96255c6d 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -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 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 = `\n${
+ formattedRows.map((r) => {
+ const safeName = escapeHtml(r.name);
+ const safeDate = r.date ? ` — ${escapeHtml(r.date)}` : '';
+ return ` - ${safeName}${safeDate}
`;
+ }).join('\n')
+ }\n
`;
+
+ 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`,
+ });
}
/**
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index 5e07f517..ecab43cb 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -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
+ // 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');