{/* Results dropdown — inline (not a popover) since this is
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 74ff2c1d..be0d9a66 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -2970,7 +2970,8 @@
"savedDiff": "Zuweisungen aktualisiert: {{parts}}",
"addedN": "{{count}} hinzugefügt",
"removedN": "{{count}} entfernt",
- "error": "Zuweisungen konnten nicht aktualisiert werden"
+ "error": "Zuweisungen konnten nicht aktualisiert werden",
+ "clearSearchAria": "Suche löschen"
}
},
"clients": {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 232151c6..24d31111 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2970,7 +2970,8 @@
"savedDiff": "Assignments updated: {{parts}}",
"addedN": "{{count}} added",
"removedN": "{{count}} removed",
- "error": "Could not update assignments"
+ "error": "Could not update assignments",
+ "clearSearchAria": "Clear search"
}
},
"clients": {
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index e9578d9d..3eac8951 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -2924,7 +2924,8 @@
"savedDiff": "Assignations mises à jour : {{parts}}",
"addedN": "{{count}} ajoutée(s)",
"removedN": "{{count}} retirée(s)",
- "error": "Impossible de mettre à jour les assignations"
+ "error": "Impossible de mettre à jour les assignations",
+ "clearSearchAria": "Effacer la recherche"
}
},
"clients": {
diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json
index b72d6864..bd44f02b 100644
--- a/frontend/src/i18n/locales/nl.json
+++ b/frontend/src/i18n/locales/nl.json
@@ -2970,7 +2970,8 @@
"savedDiff": "Toewijzingen bijgewerkt: {{parts}}",
"addedN": "{{count}} toegevoegd",
"removedN": "{{count}} verwijderd",
- "error": "Toewijzingen konden niet worden bijgewerkt"
+ "error": "Toewijzingen konden niet worden bijgewerkt",
+ "clearSearchAria": "Zoekopdracht wissen"
}
},
"clients": {
diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json
index d9c36027..c47e4126 100644
--- a/frontend/src/i18n/locales/pt.json
+++ b/frontend/src/i18n/locales/pt.json
@@ -3003,7 +3003,8 @@
"savedDiff": "Atribuições atualizadas: {{parts}}",
"addedN": "{{count}} adicionada(s)",
"removedN": "{{count}} removida(s)",
- "error": "Não foi possível atualizar as atribuições"
+ "error": "Não foi possível atualizar as atribuições",
+ "clearSearchAria": "Limpar pesquisa"
}
},
"clients": {
diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json
index 61e73094..e4ccd633 100644
--- a/frontend/src/i18n/locales/ru.json
+++ b/frontend/src/i18n/locales/ru.json
@@ -3036,7 +3036,8 @@
"savedDiff": "Назначения обновлены: {{parts}}",
"addedN": "добавлено: {{count}}",
"removedN": "удалено: {{count}}",
- "error": "Не удалось обновить назначения"
+ "error": "Не удалось обновить назначения",
+ "clearSearchAria": "Очистить поиск"
}
},
"clients": {
From 9e418c759ce508adf6025e0740468d8229938ffe Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 12 May 2026 00:07:50 +0200
Subject: [PATCH 6/8] fix(customer): don't log customer out on transient
session-refresh errors
---
frontend/src/contexts/CustomerAuthContext.tsx | 45 ++++++++++++-------
frontend/src/services/customer.service.ts | 27 ++++++++++-
2 files changed, 55 insertions(+), 17 deletions(-)
diff --git a/frontend/src/contexts/CustomerAuthContext.tsx b/frontend/src/contexts/CustomerAuthContext.tsx
index a21e2043..8297a1b8 100644
--- a/frontend/src/contexts/CustomerAuthContext.tsx
+++ b/frontend/src/contexts/CustomerAuthContext.tsx
@@ -84,22 +84,37 @@ export const CustomerAuthProvider: React.FC = ({ children }) => {
* mount-time sessionStorage cache and stays stale until a hard reload.
*/
const refreshSession = React.useCallback(async () => {
+ // Contract (see customerService.session()):
+ // - object → fresh data, store it.
+ // - null → server says we're explicitly logged out (401);
+ // clear local state.
+ // - throw → transient error (network blip, 5xx, timeout).
+ // KEEP whatever state we have — logging the user out
+ // on a flaky network call is the wrong default. The
+ // old code clobbered local state on any error, which
+ // caused mysterious "customer keeps getting kicked
+ // out" reports during unrelated admin saves and on
+ // brief connection drops.
+ let response: Awaited>;
try {
- const response = await customerService.session();
- if (response?.customer) {
- setCustomerState(response.customer);
- setFeatures(response.features);
- setBranding(response.branding);
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
- sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
- sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
- } else {
- setCustomerState(null);
- sessionStorage.removeItem(STORAGE_KEY);
- sessionStorage.removeItem(FEATURES_KEY);
- sessionStorage.removeItem(BRANDING_KEY);
- }
- } catch {
+ response = await customerService.session();
+ } catch (err) {
+ // Transient. Don't touch state. The next focus/visibility tick
+ // will retry; if the customer really is unauthenticated the
+ // retry will see the 401 and clear properly.
+ // eslint-disable-next-line no-console
+ console.warn('[CustomerAuth] session refresh failed transiently, keeping current state', err);
+ return;
+ }
+ if (response?.customer) {
+ setCustomerState(response.customer);
+ setFeatures(response.features);
+ setBranding(response.branding);
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
+ sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
+ sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
+ } else {
+ // Explicit 401 — server says no.
setCustomerState(null);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
diff --git a/frontend/src/services/customer.service.ts b/frontend/src/services/customer.service.ts
index 61a49184..216b974c 100644
--- a/frontend/src/services/customer.service.ts
+++ b/frontend/src/services/customer.service.ts
@@ -126,6 +126,23 @@ export const customerService = {
}
},
+ /**
+ * Resolve the current customer session.
+ *
+ * Return contract:
+ * - object → fresh customer + features + branding from the server.
+ * - null → backend says we are NOT authenticated (401). The
+ * caller should clear local state and bounce to login.
+ * - throws → any other error (network blip, 5xx, timeout, 410
+ * from a feature-flag flip mid-flight). The caller
+ * should KEEP whatever state it has — punishing the
+ * user with a logout for a transient failure is the
+ * wrong default. Previously this catch swallowed
+ * everything and returned null, which logged the
+ * customer out on the slightest server hiccup
+ * (including the brief window while the admin saves
+ * an unrelated change like gallery assignments).
+ */
async session(): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
@@ -142,8 +159,14 @@ export const customerService = {
features: response.data.features || { calendar: false, quotes: false, bills: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
- } catch {
- return null;
+ } catch (error: any) {
+ // Only treat an explicit 401 as "session is gone". Anything else
+ // (network failure, server 500, etc.) is a transient problem
+ // and should not log the customer out.
+ if (error?.response?.status === 401) {
+ return null;
+ }
+ throw error;
}
},
From 3f4419356a4f30509052a6d00b71485af2c17f85 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 12 May 2026 00:40:17 +0200
Subject: [PATCH 7/8] revert(customer-portal): make the global flag UI-only,
drop the kill-switch middleware
---
backend/server.js | 48 +++++++-------
.../src/middleware/requireCustomerPortal.js | 62 -------------------
backend/src/routes/customerAuth.js | 23 ++++---
3 files changed, 41 insertions(+), 92 deletions(-)
delete mode 100644 backend/src/middleware/requireCustomerPortal.js
diff --git a/backend/server.js b/backend/server.js
index 5048b4a1..dd0d9ee2 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -568,30 +568,36 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
-// Customer portal (#354). The customerPortal feature flag is enforced
-// in TWO places:
+// Customer portal (#354). The customerPortal feature flag is a
+// VISIBILITY toggle for the admin surface, not a kill switch for
+// customer access. Enforcement:
+//
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
-// (handles navigation cleanly when an admin is using the app).
-// 2. Backend: the requireCustomerPortalEnabled middleware below.
-// Belt-and-braces — a stale tab, a saved bookmark, or any
-// third-party API client trying to hit /api/customer/* or
-// /api/admin/customers/* gets a 410 Gone the moment the toggle
-// is flipped off. Includes /api/customer/auth/login: flag off
-// = nobody can log in until the admin re-enables, including
-// already-issued customers (their sessions still have valid
-// JWTs but every API call returns 410 → frontend boots them
-// out). PR #458 deliberate departure from the prior design
-// that left login alive when the rest of the surface was off.
-const {
- requireCustomerPortalEnabled,
- requireCustomerPortalEnabledAdmin,
-} = require('./src/middleware/requireCustomerPortal');
-
-app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
+// hide the Clients section when the flag is off. Customer-side
+// /customer/* surfaces stay reachable.
+// 2. Backend: NO route-level gate. The admin surface is gated by
+// adminAuth + permission checks (admin still has rights to
+// manage customer records even if the section is hidden in
+// their UI). The customer surface is gated by customerAuth +
+// is_active checks on customer_accounts.
+//
+// For close-to-realtime access changes use the dedicated tools:
+// - Revoke a customer's access to ONE gallery → "Manage galleries"
+// dialog removes the event_customer_assignments row, which
+// verifyGalleryAccess re-checks on every customer-minted JWT.
+// - Lock out a customer entirely → "Deactivate" sets is_active=false
+// and bumps password_changed_at, killing every outstanding JWT.
+// - Toggle per-customer feature surfaces (calendar/quotes/bills)
+// → toggles on the customer detail page.
+//
+// Putting the global flag in the kill-switch role was a mistake — a
+// stray click in Settings → Features would lock every paying
+// customer out at once. PR-revert moved the gate back to per-record.
+app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
// Customer-side surface (#354). Strictly separate from /api/admin/* —
// distinct token type, distinct cookie, distinct middleware.
-app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
-app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
+app.use('/api/customer/auth', require('./src/routes/customerAuth'));
+app.use('/api/customer', require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
diff --git a/backend/src/middleware/requireCustomerPortal.js b/backend/src/middleware/requireCustomerPortal.js
deleted file mode 100644
index 5b307e71..00000000
--- a/backend/src/middleware/requireCustomerPortal.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Customer portal feature-flag gate.
- *
- * Blocks every /api/customer/* and /api/admin/customers/* endpoint
- * when the `customerPortal` flag is off. Returns 410 Gone so the
- * frontend can distinguish "feature has been disabled" from "you
- * don't have access" (which would be 403) — useful for the customer
- * dashboard's auto-redirect on a soft-kill scenario.
- *
- * Reads the flag via customerAccountsService.isCustomerPortalEnabled
- * (which itself reads from the maintainer's feature_flags table),
- * so a single source of truth.
- */
-
-const customerAccountsService = require('../services/customerAccountsService');
-const logger = require('../utils/logger');
-
-async function isEnabled() {
- try {
- return await customerAccountsService.isCustomerPortalEnabled();
- } catch (err) {
- // Defensive: if the lookup throws (DB unavailable, table missing
- // mid-migration), fail closed so an enabled-by-default fallback
- // can't accidentally expose customer surfaces during boot.
- logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', {
- error: err?.message,
- });
- return false;
- }
-}
-
-/**
- * Customer-facing endpoints. Returns 410 with a code the frontend
- * can interpret to clear stale session storage + redirect to
- * /admin/login.
- */
-async function requireCustomerPortalEnabled(req, res, next) {
- if (await isEnabled()) return next();
- return res.status(410).json({
- error: 'Customer portal is disabled',
- code: 'CUSTOMER_PORTAL_DISABLED',
- });
-}
-
-/**
- * Admin-facing /api/admin/customers/* endpoints. Same gate, same
- * status code — keeps the contract consistent across both halves of
- * the customer-portal surface. The sidebar UI already hides the
- * entry, but a stale tab or direct API call must also be blocked.
- */
-async function requireCustomerPortalEnabledAdmin(req, res, next) {
- if (await isEnabled()) return next();
- return res.status(410).json({
- error: 'Customer portal is disabled',
- code: 'CUSTOMER_PORTAL_DISABLED',
- });
-}
-
-module.exports = {
- requireCustomerPortalEnabled,
- requireCustomerPortalEnabledAdmin,
-};
diff --git a/backend/src/routes/customerAuth.js b/backend/src/routes/customerAuth.js
index 87eb7b4b..8de968d8 100644
--- a/backend/src/routes/customerAuth.js
+++ b/backend/src/routes/customerAuth.js
@@ -48,15 +48,20 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// ---- login -------------------------------------------------------------
-// Flag-gate note: this route IS now gated by the customerPortal feature
-// flag via the requireCustomerPortalEnabled middleware mounted in
-// server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`).
-// When the admin flips the toggle off in Settings → Features, every
-// customer-side endpoint — including login — returns 410. The previous
-// design left login reachable while the rest of the surface was gated;
-// that was confusing and asymmetric. Single source of truth wins.
-// To lock out a specific customer without disabling the feature for
-// everyone, deactivate the account (customer_accounts.is_active = false).
+// The customerPortal feature flag deliberately does NOT gate this route.
+// Flipping the master toggle off in Settings → Features hides the
+// admin-side Clients section (sidebar entry, /admin/clients pages) but
+// must not revoke access for customers who already accepted an
+// invitation — that would mean a stray click in the Features tab
+// locks every paying customer out at once.
+//
+// To revoke access at the customer level, use the per-record tools:
+// - "Deactivate" on the customer detail page → sets
+// customer_accounts.is_active = false AND bumps password_changed_at,
+// which customerAuth rejects below + on every protected route.
+// - "Manage galleries" dialog → removes event_customer_assignments
+// rows, which verifyGalleryAccess re-checks on customer-minted
+// gallery JWTs (instant per-gallery revocation).
router.post('/login', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(),
From c02c947463011de80d9af3e947fc6d1caadc3313 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 12 May 2026 01:25:12 +0200
Subject: [PATCH 8/8] feat(customers): email customer when admin adds new
gallery access
---
..._add_customer_gallery_assigned_template.js | 243 ++++++++++++++++++
.../src/services/customerAccountsService.js | 118 ++++++++-
backend/src/services/emailProcessor.js | 11 +-
3 files changed, 368 insertions(+), 4 deletions(-)
create mode 100644 backend/migrations/core/101_add_customer_gallery_assigned_template.js
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 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:
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:
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}} новым галереям в вашем аккаунте:
Если кнопка не работает, скопируйте эту ссылку в браузер:
+{{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 = `
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');