feat(email-templates): categorise + sub-categorise + link to feature flags

This commit is contained in:
Luca
2026-05-11 20:56:28 +02:00
parent 358f7ee99e
commit 2cae3fe47d
3 changed files with 74 additions and 28 deletions
@@ -1,18 +1,30 @@
/** /**
* Migration: Categorise email templates + link them to feature flags. * 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. * 'core' — gallery delivery, admin, system, backups.
* Always visible, no feature flag. * Always visible, no feature flag.
* 'customers' — customer-portal lifecycle (invitation, etc.). * 'customers' — customer-portal lifecycle (invitation, reset).
* 'billing' — Bills feature (#354, not yet built). * 'billing' — Bills feature (#354, not yet built).
* 'quotes' — Quotes feature (#354, not yet built). * 'quotes' — Quotes feature (#354, not yet built).
* 'calendar' — Calendar feature (#354, not yet built). * 'calendar' — Calendar feature (#354, not yet built).
* Values outside this set are accepted (forward-compat) but the * Values outside this set are accepted (forward-compat) but the
* UI will lump them under 'core' for now. * 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 * - `feature_flag` — name of the feature flag whose `false` value
* should mark this template as "Feature off" in the admin UI. * should mark this template as "Feature off" in the admin UI.
* NULL means the template is always active (gallery delivery, * 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'); const hasFeatureFlag = await knex.schema.hasColumn('email_templates', 'feature_flag');
if (!hasFeatureFlag) { if (!hasFeatureFlag) {
await knex.schema.alterTable('email_templates', (table) => { 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 // a row that's been renamed. Templates not in this map keep the
// 'core' / NULL defaults from the column definitions above. // 'core' / NULL defaults from the column definitions above.
const TEMPLATE_METADATA = { const TEMPLATE_METADATA = {
// Core galleries — always-on, no flag. // Core / Galleries — gallery delivery lifecycle.
gallery_created: { category: 'core', feature_flag: null }, gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
expiration_warning: { category: 'core', feature_flag: null }, expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
gallery_expired: { category: 'core', feature_flag: null }, gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
archive_complete: { category: 'core', feature_flag: null }, archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
// Admin / RBAC — always-on (admin login is foundational). // Core / Admin — admin account lifecycle.
admin_invitation: { category: 'core', feature_flag: null }, admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
admin_password_reset: { category: 'core', feature_flag: null }, admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
// System — backups, restores, version-update notifications. // Core / Backup — database + file backups + restores.
database_backup_completed: { category: 'core', feature_flag: null }, database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
database_backup_failed: { category: 'core', feature_flag: null }, database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_completed: { category: 'core', feature_flag: null }, restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_failed: { category: 'core', feature_flag: null }, restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_completed: { category: 'core', feature_flag: null }, backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_failed: { category: 'core', feature_flag: null }, backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
version_update_available: { category: 'core', feature_flag: null }, // Core / System — version-update notifications.
version_update_test: { category: 'core', feature_flag: null }, version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
// Customer portal (#354). customer_invitation is the only template version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
// here today; calendar / quotes / bills get their templates added // Customer portal (#354). Admin-triggered password reset for
// when those features ship. // customer accounts ships in the same feature, so both templates
customer_invitation: { category: 'customers', feature_flag: 'customerPortal' }, // 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)) { for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
await knex('email_templates') await knex('email_templates')
.where({ template_key: key }) .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')) { if (await knex.schema.hasColumn('email_templates', 'category')) {
await knex.schema.alterTable('email_templates', (table) => { await knex.schema.alterTable('email_templates', (table) => {
table.dropColumn('category'); table.dropColumn('category');
+2
View File
@@ -320,6 +320,7 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
// 'core' / null fall-backs so the frontend keeps working // 'core' / null fall-backs so the frontend keeps working
// without a hard dependency on the new columns. // without a hard dependency on the new columns.
category: template.category || 'core', category: template.category || 'core',
subcategory: template.subcategory || null,
feature_flag: template.feature_flag || null, feature_flag: template.feature_flag || null,
updated_at: template.updated_at, updated_at: template.updated_at,
}); });
@@ -352,6 +353,7 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
translations, translations,
// See list endpoint for the rationale on the || fallbacks. // See list endpoint for the rationale on the || fallbacks.
category: template.category || 'core', category: template.category || 'core',
subcategory: template.subcategory || null,
feature_flag: template.feature_flag || null, feature_flag: template.feature_flag || null,
updated_at: template.updated_at, updated_at: template.updated_at,
}); });
+14 -3
View File
@@ -18,12 +18,17 @@ export interface EmailTemplateTranslation {
} }
/** /**
* Categorisation tag added by migration 098. Drives the admin * Top-level grouping in the admin Templates UI. Forward-compatible:
* Templates tab grouping — see EmailConfigPage. Forward-compatible: * unknown values fall back to the 'core' section.
* unknown values fall back to the 'core' section in the UI.
*/ */
export type EmailTemplateCategory = 'core' | 'customers' | 'billing' | 'quotes' | 'calendar' | string; 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 { export interface EmailTemplate {
id: number; id: number;
template_key: string; template_key: string;
@@ -31,6 +36,12 @@ export interface EmailTemplate {
translations: Record<string, EmailTemplateTranslation>; translations: Record<string, EmailTemplateTranslation>;
/** Display group (migration 098). Defaults to 'core' if absent. */ /** Display group (migration 098). Defaults to 'core' if absent. */
category?: EmailTemplateCategory; 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 * Name of the feature flag whose `false` state should mark this
* template as "Feature off" in the admin UI. `null` = always * template as "Feature off" in the admin UI. `null` = always