fix(email): give every template a real display name in the config UI

D4 audit found defaultTemplateKeys was worse than stale sample data:

- Only .name was ever read. The subject/body/variables triple on each entry
  was dead data -- and it is where {{password}} and {{expiration_date}}
  originated, neither of which exists in any shipped template (they are
  gallery_password and expiry_date).
- It covered 4 keys out of ~40. A fresh install already carries 17 templates,
  and ~40 with the CRM flags on. Every key not in the list rendered its raw
  snake_case template_key as its display name in both the sidebar and the
  read-only "Template name" field -- customer_gallery_assigned,
  database_backup_completed, invoice_collections_handoff, all five
  event_reminder_*, and so on.

Replaced with TEMPLATE_DISPLAY_NAMES covering every key from
migrations/core/*.js plus the crm/contract/eventReminder template services,
falling back to the raw key. Drops the now-orphaned password sample value.

Adjacent drift found, not fixed (different const, and fixing it would be
scope creep): eventReminderTemplates.js inserts with category 'crm' /
subcategory 'event_reminder', neither of which is in CATEGORY_ORDER or
CORE_SUBCATEGORY_ORDER, so all five reminder templates fall through the
unknown-category fallback into core -> "other". They are visible, just filed
in the wrong bucket.

Refs testplan REPORT.md D4.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 41e1de7818
commit 355fe4ff43
+71 -67
View File
@@ -80,7 +80,6 @@ const PREVIEW_SAMPLE_VALUES: Record<string, string> = {
expiry_date: 'January 25, 2025', expiry_date: 'January 25, 2025',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding', gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
gallery_password: '••••••••', gallery_password: '••••••••',
password: '••••••••',
host_name: 'Jane Doe', host_name: 'Jane Doe',
host_email: '[email protected]', host_email: '[email protected]',
admin_email: '[email protected]', admin_email: '[email protected]',
@@ -101,69 +100,74 @@ export const buildPreviewSampleData = (variables: string[] = []): Record<string,
variables.map((name) => [name, PREVIEW_SAMPLE_VALUES[name] ?? `[${name}]`]) variables.map((name) => [name, PREVIEW_SAMPLE_VALUES[name] ?? `[${name}]`])
); );
const defaultTemplateKeys = [ /**
{ * Display name per `template_key`, for the sidebar entry and the read-only
key: 'gallery_created', * "Template name" field. Anything not listed falls back to the raw key.
name: 'Gallery Created', *
subject: 'Your {{event_name}} photos are ready!', * This replaces `defaultTemplateKeys`, which carried a stand-in
body: `Hi there! * subject/body/variables triple per template. That payload was dead — only
* the name was ever read — and it had drifted: its {{password}} and
Your photo gallery for {{event_name}} is now ready to view. * {{expiration_date}} tokens exist in no shipped template (they are
* {{gallery_password}} and {{expiry_date}}), which is where the stale preview
Event: {{event_name}} * sample keys came from. It also covered four keys, so every other template
Date: {{event_date}} * rendered its raw snake_case key as its name.
Password: {{password}} *
* Keys come from backend/migrations/core/*.js (core, customers, transfers)
You can access your photos here: {{gallery_link}} * and backend/src/services/{crm,contract,eventReminder}EmailTemplates.js
* (quotes, billing, contracts, event reminders).
Your gallery will be available until {{expiration_date}}. Make sure to download your photos before they expire! */
const TEMPLATE_DISPLAY_NAMES: Record<string, string> = {
{{#if welcome_message}} // core / gallery
Personal message from your host: gallery_created: 'Gallery Created',
{{welcome_message}} expiration_warning: 'Expiration Warning',
{{/if}} gallery_expired: 'Gallery Expired',
archive_complete: 'Archive Complete (Admin)',
Best regards, // core / admin
The Photo Sharing Team`, admin_invitation: 'Admin Invitation',
variables: ['event_name', 'event_date', 'password', 'gallery_link', 'expiration_date', 'welcome_message'] admin_password_reset: 'Admin Password Reset',
}, // core / backup
{ backup_completed: 'Backup Completed',
key: 'expiration_warning', backup_failed: 'Backup Failed',
name: 'Expiration Warning', database_backup_completed: 'Database Backup Completed',
subject: 'Your {{event_name}} photos expire in {{days_remaining}} days!', database_backup_failed: 'Database Backup Failed',
body: `Important: Your photo gallery is expiring soon! restore_completed: 'Restore Completed',
restore_failed: 'Restore Failed',
Your photos from {{event_name}} will no longer be available after {{expiration_date}}. // core / system
version_update_available: 'Version Update Available',
You have {{days_remaining}} days remaining to download your photos. version_update_test: 'Version Update (Test)',
// core / transfers
Access your gallery here: {{gallery_link}} transfer_ready: 'Transfer Ready',
transfer_link_expired: 'Transfer Link Expired',
Don't forget to download all your favorite memories before they're gone! // customers
customer_invitation: 'Customer Invitation',
Best regards, customer_password_reset: 'Customer Password Reset',
The Photo Sharing Team`, customer_gallery_assigned: 'Gallery Assigned to Customer',
variables: ['event_name', 'days_remaining', 'expiration_date', 'gallery_link'] // quotes
}, quote_sent: 'Quote Sent',
{ quote_accepted_customer: 'Quote Accepted (Customer)',
key: 'gallery_expired', quote_accepted_admin: 'Quote Accepted (Admin)',
name: 'Gallery Expired', quote_declined_admin: 'Quote Declined (Admin)',
subject: 'Your {{event_name}} photo gallery has expired', // contracts
body: `Your photo gallery for {{event_name}} has expired and is no longer accessible. contract_sent: 'Contract Sent',
contract_fully_signed: 'Contract Fully Signed',
The photos have been archived for safekeeping. If you need access to them, please contact the event administrator at {{admin_email}}. contract_signed_admin_notification: 'Contract Signed (Admin)',
// billing
Thank you for using our photo sharing service! invoice_sent: 'Invoice Sent',
invoice_reminder_first: 'Invoice Reminder (1st)',
Best regards, invoice_reminder_second: 'Invoice Reminder (2nd)',
The Photo Sharing Team`, invoice_paid_receipt: 'Invoice Paid — Receipt',
variables: ['event_name', 'admin_email'] invoice_paid_admin_notification: 'Invoice Paid (Admin)',
}, invoice_cancelled: 'Invoice Cancelled',
{ invoice_payment_check: 'Payment Check (Admin)',
key: 'archive_complete', invoice_collections_handoff: 'Collections Handoff',
name: 'Archive Complete (Admin)', storno_issued: 'Credit Note Issued',
} // event reminders
]; event_reminder_default: 'Event Reminder (Default)',
event_reminder_wedding: 'Event Reminder (Wedding)',
event_reminder_birthday: 'Event Reminder (Birthday)',
event_reminder_corporate: 'Event Reminder (Corporate)',
event_reminder_other: 'Event Reminder (Other)',
};
export const EmailConfigPage: React.FC = () => { export const EmailConfigPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -859,7 +863,7 @@ export const EmailConfigPage: React.FC = () => {
// 2. Renders a single template button. Pulled out so the // 2. Renders a single template button. Pulled out so the
// flat path and the sub-category path share it. // flat path and the sub-category path share it.
const renderTemplate = (template: EmailTemplate) => { const renderTemplate = (template: EmailTemplate) => {
const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key); const templateName = TEMPLATE_DISPLAY_NAMES[template.template_key] || template.template_key;
const translationCount = getTranslationCount(template); const translationCount = getTranslationCount(template);
const enTranslation = template.translations?.en; const enTranslation = template.translations?.en;
const featureOff = template.feature_flag const featureOff = template.feature_flag
@@ -880,7 +884,7 @@ export const EmailConfigPage: React.FC = () => {
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate"> <p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
{templateInfo?.name || template.template_key} {templateName}
</p> </p>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{featureOff && ( {featureOff && (
@@ -1032,7 +1036,7 @@ export const EmailConfigPage: React.FC = () => {
</label> </label>
<Input <Input
type="text" type="text"
value={defaultTemplateKeys.find(t => t.key === selectedTemplateKey)?.name || selectedTemplateKey} value={TEMPLATE_DISPLAY_NAMES[selectedTemplateKey] || selectedTemplateKey}
disabled disabled
className="bg-neutral-50 dark:bg-neutral-700" className="bg-neutral-50 dark:bg-neutral-700"
/> />