diff --git a/backend/migrations/core/098_add_email_template_category.js b/backend/migrations/core/098_add_email_template_category.js index e21806d1..a3e44a0c 100644 --- a/backend/migrations/core/098_add_email_template_category.js +++ b/backend/migrations/core/098_add_email_template_category.js @@ -1,18 +1,30 @@ /** * Migration: Categorise email templates + link them to feature flags. * - * Adds two metadata columns to `email_templates`: + * Adds three metadata columns to `email_templates`: * - * - `category` — display group in the admin Templates UI. One of: + * - `category` — top-level display group in the admin Templates UI. + * One of: * 'core' — gallery delivery, admin, system, backups. * Always visible, no feature flag. - * 'customers' — customer-portal lifecycle (invitation, etc.). + * 'customers' — customer-portal lifecycle (invitation, reset). * 'billing' — Bills feature (#354, not yet built). * 'quotes' — Quotes feature (#354, not yet built). * 'calendar' — Calendar feature (#354, not yet built). * Values outside this set are accepted (forward-compat) but the * UI will lump them under 'core' for now. * + * - `subcategory` — second-level group inside `core` (which is busy + * with 14 templates). One of: + * 'gallery' — gallery delivery lifecycle (created / expiring / + * expired / archived). + * 'admin' — admin lifecycle (invitation, password reset). + * 'backup' — DB + file backups (completed / failed) and + * restores. + * 'system' — version update notifications. + * Only meaningful when category='core'; other categories ignore + * it. NULL on rows that don't need a sub-bucket. + * * - `feature_flag` — name of the feature flag whose `false` value * should mark this template as "Feature off" in the admin UI. * NULL means the template is always active (gallery delivery, @@ -37,6 +49,13 @@ exports.up = async function(knex) { }); } + const hasSubcategory = await knex.schema.hasColumn('email_templates', 'subcategory'); + if (!hasSubcategory) { + await knex.schema.alterTable('email_templates', (table) => { + table.string('subcategory', 32).nullable(); + }); + } + const hasFeatureFlag = await knex.schema.hasColumn('email_templates', 'feature_flag'); if (!hasFeatureFlag) { await knex.schema.alterTable('email_templates', (table) => { @@ -48,33 +67,41 @@ exports.up = async function(knex) { // a row that's been renamed. Templates not in this map keep the // 'core' / NULL defaults from the column definitions above. const TEMPLATE_METADATA = { - // Core galleries — always-on, no flag. - gallery_created: { category: 'core', feature_flag: null }, - expiration_warning: { category: 'core', feature_flag: null }, - gallery_expired: { category: 'core', feature_flag: null }, - archive_complete: { category: 'core', feature_flag: null }, - // Admin / RBAC — always-on (admin login is foundational). - admin_invitation: { category: 'core', feature_flag: null }, - admin_password_reset: { category: 'core', feature_flag: null }, - // System — backups, restores, version-update notifications. - database_backup_completed: { category: 'core', feature_flag: null }, - database_backup_failed: { category: 'core', feature_flag: null }, - restore_completed: { category: 'core', feature_flag: null }, - restore_failed: { category: 'core', feature_flag: null }, - backup_completed: { category: 'core', feature_flag: null }, - backup_failed: { category: 'core', feature_flag: null }, - version_update_available: { category: 'core', feature_flag: null }, - version_update_test: { category: 'core', feature_flag: null }, - // Customer portal (#354). customer_invitation is the only template - // here today; calendar / quotes / bills get their templates added - // when those features ship. - customer_invitation: { category: 'customers', feature_flag: 'customerPortal' }, + // Core / Galleries — gallery delivery lifecycle. + gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null }, + expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null }, + gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null }, + archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null }, + // Core / Admin — admin account lifecycle. + admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null }, + admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null }, + // Core / Backup — database + file backups + restores. + database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null }, + database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null }, + restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null }, + restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null }, + backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null }, + backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null }, + // Core / System — version-update notifications. + version_update_available: { category: 'core', subcategory: 'system', feature_flag: null }, + version_update_test: { category: 'core', subcategory: 'system', feature_flag: null }, + // Customer portal (#354). Admin-triggered password reset for + // customer accounts ships in the same feature, so both templates + // share the `customers` category and the `customerPortal` flag. + // Future calendar / quotes / bills templates will land here under + // their own categories. + customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, + customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, }; for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) { await knex('email_templates') .where({ template_key: key }) - .update({ category: meta.category, feature_flag: meta.feature_flag }); + .update({ + category: meta.category, + subcategory: meta.subcategory, + feature_flag: meta.feature_flag, + }); } }; @@ -87,6 +114,12 @@ exports.down = async function(knex) { }); } + if (await knex.schema.hasColumn('email_templates', 'subcategory')) { + await knex.schema.alterTable('email_templates', (table) => { + table.dropColumn('subcategory'); + }); + } + if (await knex.schema.hasColumn('email_templates', 'category')) { await knex.schema.alterTable('email_templates', (table) => { table.dropColumn('category'); diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 8fb8a2c5..29064649 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -320,6 +320,7 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req, // 'core' / null fall-backs so the frontend keeps working // without a hard dependency on the new columns. category: template.category || 'core', + subcategory: template.subcategory || null, feature_flag: template.feature_flag || null, updated_at: template.updated_at, }); @@ -352,6 +353,7 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async translations, // See list endpoint for the rationale on the || fallbacks. category: template.category || 'core', + subcategory: template.subcategory || null, feature_flag: template.feature_flag || null, updated_at: template.updated_at, }); diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index a680c96f..66f7ac17 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -18,12 +18,17 @@ export interface EmailTemplateTranslation { } /** - * Categorisation tag added by migration 098. Drives the admin - * Templates tab grouping — see EmailConfigPage. Forward-compatible: - * unknown values fall back to the 'core' section in the UI. + * Top-level grouping in the admin Templates UI. Forward-compatible: + * unknown values fall back to the 'core' section. */ export type EmailTemplateCategory = 'core' | 'customers' | 'billing' | 'quotes' | 'calendar' | string; +/** + * Second-level grouping inside 'core' (which is busy enough to deserve + * its own sub-headers). Other categories ignore this field. + */ +export type EmailTemplateSubcategory = 'gallery' | 'admin' | 'backup' | 'system' | string; + export interface EmailTemplate { id: number; template_key: string; @@ -31,6 +36,12 @@ export interface EmailTemplate { translations: Record; /** Display group (migration 098). Defaults to 'core' if absent. */ category?: EmailTemplateCategory; + /** + * Second-level group inside `core`. Migration 098 backfill assigns + * one of 'gallery' | 'admin' | 'backup' | 'system'; NULL for + * templates outside `core`. + */ + subcategory?: EmailTemplateSubcategory | null; /** * Name of the feature flag whose `false` state should mark this * template as "Feature off" in the admin UI. `null` = always