diff --git a/backend/migrations/core/098_add_email_template_category.js b/backend/migrations/core/098_add_email_template_category.js new file mode 100644 index 00000000..a3e44a0c --- /dev/null +++ b/backend/migrations/core/098_add_email_template_category.js @@ -0,0 +1,128 @@ +/** + * Migration: Categorise email templates + link them to feature flags. + * + * Adds three metadata columns to `email_templates`: + * + * - `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, 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, + * admin lifecycle, system notifications). + * + * Categorisation does NOT hide templates. Disabled-feature templates + * stay visible and editable so admins can prep them before a feature + * launch; the UI shows a small "Feature off" chip on the entry. + * + * Idempotent: re-runs are no-ops. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + const hasCategory = await knex.schema.hasColumn('email_templates', 'category'); + if (!hasCategory) { + await knex.schema.alterTable('email_templates', (table) => { + // Default 'core' so existing rows aren't NULL; the backfill below + // overrides for templates that belong to a feature group. + table.string('category', 32).notNullable().defaultTo('core'); + }); + } + + 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) => { + table.string('feature_flag', 64).nullable(); + }); + } + + // Backfill — keyed by template_key so we don't accidentally update + // 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 — 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, + subcategory: meta.subcategory, + feature_flag: meta.feature_flag, + }); + } +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + if (await knex.schema.hasColumn('email_templates', 'feature_flag')) { + await knex.schema.alterTable('email_templates', (table) => { + table.dropColumn('feature_flag'); + }); + } + + 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/migrations/core/099_seed_missing_email_template_translations.js b/backend/migrations/core/099_seed_missing_email_template_translations.js new file mode 100644 index 00000000..38c93574 --- /dev/null +++ b/backend/migrations/core/099_seed_missing_email_template_translations.js @@ -0,0 +1,797 @@ +/** + * Migration: Auto-fill missing email-template translations for nl / pt / + * ru / fr — plus the en/de rows for templates that were seeded AFTER + * migration 075 ran (customer_password_reset from 092, version_update_test + * from 087). Those two carry their EN/DE content in the legacy + * subject_en/body_html_en/... columns; without a row in + * email_template_translations, the admin Templates UI shows them as + * empty until an admin clicks save. + * + * Coverage going in: + * - gallery_created / expiration_warning / gallery_expired / + * archive_complete already had en/de/nl/pt/ru from migration 075 + * → this migration adds the missing `fr` row. + * - admin_*, backup_*, restore_*, database_backup_*, customer_invitation, + * version_update_available had en/de only → this migration adds + * nl/pt/ru/fr. + * - customer_password_reset + version_update_test had legacy-column + * EN/DE only (post-075 inserts) → this migration adds the full + * en/de/nl/pt/ru/fr set, sourcing en/de from the legacy columns + * when present and falling back to the curated copy below. + * + * The non-EN/DE translations below were generated by an LLM and are + * flagged in the PR description as needing native-speaker review + * before the next stable release. en / de remain hand-translated. + * + * Idempotent: every insert checks (template_id, language) for an + * existing row first and skips if present. Safe to re-run. + * + * Variable placeholders ({{name}}) are preserved verbatim across all + * locales so emailProcessor's safeTemplateReplace continues to wire + * them up unchanged. + */ + +const TRANSLATIONS = { + // ──────────────────────────────────────────────────────────────── + // Gallery delivery (core) — only fr is missing, the rest landed in + // migration 075. + // ──────────────────────────────────────────────────────────────── + gallery_created: { + fr: { + subject: 'Votre galerie photo est prête !', + body_html: `

Galerie créée avec succès

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » a été créée avec succès !

+

Détails de la galerie :

+ +

Partagez ce lien et le mot de passe avec vos invités pour qu'ils puissent voir et télécharger les photos.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: `Galerie créée avec succès\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a été créée avec succès !\n\nLien de la galerie : {{gallery_link}}\nMot de passe : {{gallery_password}}\nExpire le : {{expiry_date}}`, + }, + }, + + expiration_warning: { + fr: { + subject: 'Votre galerie photo expire bientôt', + body_html: `

La galerie expire bientôt

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.

+

Après l'expiration, la galerie sera archivée et ne sera plus accessible aux invités.

+

Voir la galerie

`, + body_text: `La galerie expire bientôt\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.\n\nGalerie : {{gallery_link}}`, + }, + }, + + gallery_expired: { + fr: { + subject: 'Galerie photo expirée et archivée', + body_html: `

Galerie archivée

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » a expiré et a été archivée.

+

Les invités ne peuvent plus accéder à la galerie. Contactez votre photographe si vous avez besoin de restaurer l'accès.

`, + body_text: `Galerie archivée\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a expiré et a été archivée.`, + }, + }, + + archive_complete: { + fr: { + subject: 'Galerie archivée : {{event_name}}', + body_html: `

Galerie archivée avec succès

+

La galerie photo « {{event_name}} » a été archivée.

+

Détails de l'archive :

+`, + body_text: `Galerie archivée avec succès\n\nLa galerie photo « {{event_name}} » a été archivée.\n\nTaille : {{archive_size}}\nPhotos : {{photo_count}}\nEmplacement : {{archive_path}}`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Admin / RBAC — invitation + password reset. en/de exist, adding + // nl/pt/ru/fr. + // ──────────────────────────────────────────────────────────────── + admin_invitation: { + nl: { + subject: 'U bent uitgenodigd om deel te nemen aan PicPeak als {{role_name}}', + body_html: `

Welkom bij PicPeak

+

U bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.

+

Klik op de onderstaande link om uw account in te stellen:

+

Account instellen

+

Deze uitnodiging verloopt op {{expires_at}}.

+

Als u deze e-mail niet verwachtte, kunt u deze gerust negeren.

`, + body_text: `Welkom bij PicPeak\n\nU bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.\n\nStel uw account in: {{invitation_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`, + }, + pt: { + subject: 'Você foi convidado para o PicPeak como {{role_name}}', + body_html: `

Bem-vindo(a) ao PicPeak

+

Você foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.

+

Clique no link abaixo para configurar sua conta:

+

Configurar conta

+

Este convite expira em {{expires_at}}.

+

Se você não esperava este e-mail, pode ignorá-lo com segurança.

`, + body_text: `Bem-vindo(a) ao PicPeak\n\nVocê foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.\n\nConfigure sua conta: {{invitation_link}}\n\nEste convite expira em {{expires_at}}.`, + }, + ru: { + subject: 'Вас пригласили присоединиться к PicPeak в роли {{role_name}}', + body_html: `

Добро пожаловать в PicPeak

+

{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.

+

Перейдите по ссылке ниже, чтобы настроить учётную запись:

+

Настроить учётную запись

+

Срок действия приглашения истекает {{expires_at}}.

+

Если вы не ожидали этого письма, можете его проигнорировать.

`, + body_text: `Добро пожаловать в PicPeak\n\n{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.\n\nНастроить учётную запись: {{invitation_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`, + }, + fr: { + subject: 'Vous avez été invité(e) à rejoindre PicPeak en tant que {{role_name}}', + body_html: `

Bienvenue sur PicPeak

+

{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.

+

Cliquez sur le lien ci-dessous pour configurer votre compte :

+

Configurer le compte

+

Cette invitation expire le {{expires_at}}.

+

Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.

`, + body_text: `Bienvenue sur PicPeak\n\n{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.\n\nConfigurer le compte : {{invitation_link}}\n\nCette invitation expire le {{expires_at}}.`, + }, + }, + + admin_password_reset: { + nl: { + subject: 'Uw PicPeak-administratorwachtwoord is opnieuw ingesteld', + body_html: `

Wachtwoord opnieuw ingesteld

+

Hallo {{admin_name}},

+

Uw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.

+

Klik op de onderstaande link om een nieuw wachtwoord in te stellen:

+

Nieuw wachtwoord instellen

+

Deze link verloopt op {{expires_at}}. Heeft u deze actie niet aangevraagd? Neem dan onmiddellijk contact op met uw teambeheerder.

`, + body_text: `Wachtwoord opnieuw ingesteld\n\nHallo {{admin_name}},\n\nUw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.\n\nStel een nieuw wachtwoord in: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.`, + }, + pt: { + subject: 'Sua senha de administrador do PicPeak foi redefinida', + body_html: `

Senha redefinida

+

Olá {{admin_name}},

+

Sua senha de administrador do PicPeak foi redefinida por {{reset_by}}.

+

Clique no link abaixo para definir uma nova senha:

+

Definir nova senha

+

Este link expira em {{expires_at}}. Se você não solicitou esta ação, entre em contato com o administrador da sua equipe imediatamente.

`, + body_text: `Senha redefinida\n\nOlá {{admin_name}},\n\nSua senha de administrador do PicPeak foi redefinida por {{reset_by}}.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.`, + }, + ru: { + subject: 'Ваш пароль администратора PicPeak был сброшен', + body_html: `

Пароль сброшен

+

Здравствуйте, {{admin_name}}!

+

Ваш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.

+

Перейдите по ссылке ниже, чтобы задать новый пароль:

+

Задать новый пароль

+

Срок действия ссылки истекает {{expires_at}}. Если вы не запрашивали это действие, немедленно свяжитесь с администратором вашей команды.

`, + body_text: `Пароль сброшен\n\nЗдравствуйте, {{admin_name}}!\n\nВаш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.`, + }, + fr: { + subject: 'Votre mot de passe administrateur PicPeak a été réinitialisé', + body_html: `

Mot de passe réinitialisé

+

Bonjour {{admin_name}},

+

Votre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.

+

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :

+

Définir un nouveau mot de passe

+

Ce lien expire le {{expires_at}}. Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement votre administrateur.

`, + body_text: `Mot de passe réinitialisé\n\nBonjour {{admin_name}},\n\nVotre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Customer portal (#354). customer_invitation already has hand-tuned + // en/de from migration 090 (themed button via migration 094). + // ──────────────────────────────────────────────────────────────── + customer_invitation: { + nl: { + subject: 'U bent uitgenodigd om uw fotogalerijen te bekijken', + body_html: `

Welkom bij uw fotogalerijen

+

U bent uitgenodigd om een klantaccount aan te maken, zodat u al uw evenementgalerijen op één plek kunt bekijken — geen aparte links en wachtwoorden meer.

+
+ Account instellen +
+

Deze uitnodiging verloopt op {{expires_at}}. Werkt de link niet? Kopieer hem en plak hem in uw browser:

+

{{invite_link}}

+

Heeft u deze e-mail niet verwacht? U kunt deze gerust negeren.

`, + body_text: `Welkom bij uw fotogalerijen\n\nU bent uitgenodigd om een klantaccount aan te maken zodat u al uw galerijen op één plek kunt bekijken.\n\nAccount instellen: {{invite_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`, + }, + pt: { + subject: 'Você foi convidado(a) a acessar suas galerias de fotos', + body_html: `

Bem-vindo(a) às suas galerias

+

Você foi convidado(a) a criar uma conta de cliente para visualizar todas as suas galerias de eventos em um só lugar — sem mais links e senhas separados.

+
+ Configurar conta +
+

Este convite expira em {{expires_at}}. Se o link não funcionar, copie e cole-o no navegador:

+

{{invite_link}}

+

Se você não esperava este e-mail, pode ignorá-lo com segurança.

`, + body_text: `Bem-vindo(a) às suas galerias\n\nVocê foi convidado(a) a criar uma conta de cliente para acessar todas as suas galerias em um só lugar.\n\nConfigurar conta: {{invite_link}}\n\nEste convite expira em {{expires_at}}.`, + }, + ru: { + subject: 'Вас пригласили получить доступ к вашим фотогалереям', + body_html: `

Добро пожаловать в ваши галереи

+

Вас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи событий в одном месте — больше никаких отдельных ссылок и паролей.

+
+ Настроить учётную запись +
+

Срок действия приглашения истекает {{expires_at}}. Если ссылка не работает, скопируйте её в адресную строку браузера:

+

{{invite_link}}

+

Если вы не ожидали этого письма, можете его проигнорировать.

`, + body_text: `Добро пожаловать в ваши галереи\n\nВас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи в одном месте.\n\nНастроить учётную запись: {{invite_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`, + }, + fr: { + subject: 'Vous avez été invité(e) à accéder à vos galeries photo', + body_html: `

Bienvenue dans vos galeries photo

+

Vous avez été invité(e) à créer un compte client pour voir toutes vos galeries d'événements en un seul endroit — plus de liens et mots de passe séparés.

+
+ Configurer le compte +
+

Cette invitation expire le {{expires_at}}. Si le lien ne fonctionne pas, copiez-le et collez-le dans votre navigateur :

+

{{invite_link}}

+

Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.

`, + body_text: `Bienvenue dans vos galeries photo\n\nVous avez été invité(e) à créer un compte client pour accéder à toutes vos galeries en un seul endroit.\n\nConfigurer le compte : {{invite_link}}\n\nCette invitation expire le {{expires_at}}.`, + }, + }, + + // Customer password reset (#354 follow-up). Seeded by migration 092 + // in the legacy columns with English-only copy (the EN string was + // broadcast to every subject_*/body_html_* column to satisfy NOT + // NULL on multi-locale schemas). Replacing here with a proper + // per-locale set including hand-tuned EN/DE plus AI-generated + // nl/pt/ru/fr. + customer_password_reset: { + en: { + subject: 'Reset your customer account password', + body_html: `

Hello,

+

Your photographer has triggered a password reset for your customer account.

+

Set a new password

+

This link expires on {{expires_at}}.

+

If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.

`, + body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`, + }, + de: { + subject: 'Passwort für dein Kundenkonto zurücksetzen', + body_html: `

Hallo,

+

Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.

+

Neues Passwort festlegen

+

Dieser Link läuft am {{expires_at}} ab.

+

Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.

`, + body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`, + }, + nl: { + subject: 'Wachtwoord van uw klantaccount opnieuw instellen', + body_html: `

Hallo,

+

Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.

+

Nieuw wachtwoord instellen

+

Deze link verloopt op {{expires_at}}.

+

Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.

`, + body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`, + }, + pt: { + subject: 'Redefina a senha da sua conta de cliente', + body_html: `

Olá,

+

Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.

+

Definir nova senha

+

Este link expira em {{expires_at}}.

+

Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.

`, + body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`, + }, + ru: { + subject: 'Сброс пароля вашей клиентской учётной записи', + body_html: `

Здравствуйте!

+

Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.

+

Задать новый пароль

+

Срок действия ссылки истекает {{expires_at}}.

+

Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.

`, + body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`, + }, + fr: { + subject: 'Réinitialisez le mot de passe de votre compte client', + body_html: `

Bonjour,

+

Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.

+

Définir un nouveau mot de passe

+

Ce lien expire le {{expires_at}}.

+

Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.

`, + body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Database backups + // ──────────────────────────────────────────────────────────────── + database_backup_completed: { + nl: { + subject: '[PicPeak] Database-back-up succesvol', + body_html: `

Database-back-up voltooid

+

De geplande database-back-up is succesvol voltooid.

+`, + body_text: `Database-back-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`, + }, + pt: { + subject: '[PicPeak] Backup do banco de dados concluído', + body_html: `

Backup do banco de dados concluído

+

O backup agendado do banco de dados foi concluído com sucesso.

+`, + body_text: `Backup do banco de dados concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`, + }, + ru: { + subject: '[PicPeak] Резервная копия БД успешно создана', + body_html: `

Резервная копия базы данных создана

+

Запланированное резервное копирование базы данных успешно завершено.

+`, + body_text: `Резервная копия базы данных создана\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`, + }, + fr: { + subject: '[PicPeak] Sauvegarde de la base de données réussie', + body_html: `

Sauvegarde de la base de données terminée

+

La sauvegarde planifiée de la base de données s'est terminée avec succès.

+`, + body_text: `Sauvegarde de la base de données terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`, + }, + }, + + database_backup_failed: { + nl: { + subject: '[PicPeak] Database-back-up MISLUKT', + body_html: `

Database-back-up mislukt

+

De geplande database-back-up is mislukt en moet handmatig worden onderzocht.

+ +

Controleer de serverlogboeken voor meer details.

`, + body_text: `Database-back-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}\n\nControleer de serverlogboeken voor meer details.`, + }, + pt: { + subject: '[PicPeak] FALHA no backup do banco de dados', + body_html: `

Falha no backup do banco de dados

+

O backup agendado do banco de dados falhou e precisa de investigação manual.

+ +

Verifique os logs do servidor para mais detalhes.

`, + body_text: `Falha no backup do banco de dados\n\nHorário: {{failed_at}}\nErro: {{error_message}}\n\nVerifique os logs do servidor.`, + }, + ru: { + subject: '[PicPeak] ОШИБКА резервного копирования БД', + body_html: `

Ошибка резервного копирования базы данных

+

Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.

+ +

Проверьте журналы сервера для получения дополнительной информации.

`, + body_text: `Ошибка резервного копирования базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}\n\nПроверьте журналы сервера.`, + }, + fr: { + subject: '[PicPeak] ÉCHEC de la sauvegarde de la base de données', + body_html: `

Échec de la sauvegarde de la base de données

+

La sauvegarde planifiée de la base de données a échoué et nécessite une investigation manuelle.

+ +

Consultez les journaux du serveur pour plus de détails.

`, + body_text: `Échec de la sauvegarde de la base de données\n\nHeure : {{failed_at}}\nErreur : {{error_message}}\n\nConsultez les journaux du serveur.`, + }, + }, + + restore_completed: { + nl: { + subject: '[PicPeak] Database-herstel succesvol', + body_html: `

Database-herstel voltooid

+

De handmatige database-herstel-operatie is succesvol voltooid.

+`, + body_text: `Database-herstel voltooid\n\nTijdstip: {{completed_at}}\nHerstelpunt: {{source_backup}}`, + }, + pt: { + subject: '[PicPeak] Restauração do banco de dados concluída', + body_html: `

Restauração concluída

+

A restauração manual do banco de dados foi concluída com sucesso.

+`, + body_text: `Restauração concluída\n\nHorário: {{completed_at}}\nOrigem: {{source_backup}}`, + }, + ru: { + subject: '[PicPeak] Восстановление БД успешно завершено', + body_html: `

Восстановление базы данных завершено

+

Ручная операция восстановления базы данных успешно завершена.

+`, + body_text: `Восстановление базы данных завершено\n\nВремя: {{completed_at}}\nТочка восстановления: {{source_backup}}`, + }, + fr: { + subject: '[PicPeak] Restauration de la base de données réussie', + body_html: `

Restauration de la base terminée

+

L'opération manuelle de restauration de la base de données s'est terminée avec succès.

+`, + body_text: `Restauration terminée\n\nHeure : {{completed_at}}\nSource : {{source_backup}}`, + }, + }, + + restore_failed: { + nl: { + subject: '[PicPeak] Database-herstel MISLUKT', + body_html: `

Database-herstel mislukt

+

De handmatige database-herstel-operatie is mislukt en moet handmatig worden onderzocht.

+`, + body_text: `Database-herstel mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`, + }, + pt: { + subject: '[PicPeak] FALHA na restauração do banco de dados', + body_html: `

Falha na restauração

+

A restauração manual do banco de dados falhou e precisa de investigação.

+`, + body_text: `Falha na restauração\n\nHorário: {{failed_at}}\nErro: {{error_message}}`, + }, + ru: { + subject: '[PicPeak] ОШИБКА восстановления БД', + body_html: `

Ошибка восстановления базы данных

+

Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.

+`, + body_text: `Ошибка восстановления базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`, + }, + fr: { + subject: '[PicPeak] ÉCHEC de la restauration de la base de données', + body_html: `

Échec de la restauration

+

L'opération manuelle de restauration de la base de données a échoué et nécessite une investigation.

+`, + body_text: `Échec de la restauration\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // File backups (legacy backup_completed / backup_failed pair). + // Mostly identical content to database_backup_* but kept separate + // because the legacy keys are still wired to a different code path. + // ──────────────────────────────────────────────────────────────── + backup_completed: { + nl: { + subject: '[PicPeak] Bestandsback-up voltooid', + body_html: `

Bestandsback-up voltooid

+

De geplande bestandsback-up is succesvol voltooid.

+`, + body_text: `Bestandsback-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`, + }, + pt: { + subject: '[PicPeak] Backup de arquivos concluído', + body_html: `

Backup de arquivos concluído

+

O backup agendado de arquivos foi concluído com sucesso.

+`, + body_text: `Backup de arquivos concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`, + }, + ru: { + subject: '[PicPeak] Резервное копирование файлов завершено', + body_html: `

Резервное копирование файлов завершено

+

Запланированное резервное копирование файлов успешно завершено.

+`, + body_text: `Резервное копирование файлов завершено\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`, + }, + fr: { + subject: '[PicPeak] Sauvegarde des fichiers terminée', + body_html: `

Sauvegarde des fichiers terminée

+

La sauvegarde planifiée des fichiers s'est terminée avec succès.

+`, + body_text: `Sauvegarde des fichiers terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`, + }, + }, + + backup_failed: { + nl: { + subject: '[PicPeak] Bestandsback-up MISLUKT', + body_html: `

Bestandsback-up mislukt

+

De geplande bestandsback-up is mislukt en moet worden onderzocht.

+`, + body_text: `Bestandsback-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`, + }, + pt: { + subject: '[PicPeak] FALHA no backup de arquivos', + body_html: `

Falha no backup de arquivos

+

O backup agendado de arquivos falhou e precisa de investigação.

+`, + body_text: `Falha no backup de arquivos\n\nHorário: {{failed_at}}\nErro: {{error_message}}`, + }, + ru: { + subject: '[PicPeak] ОШИБКА резервного копирования файлов', + body_html: `

Ошибка резервного копирования файлов

+

Запланированное резервное копирование файлов завершилось с ошибкой и требует проверки.

+`, + body_text: `Ошибка резервного копирования файлов\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`, + }, + fr: { + subject: '[PicPeak] ÉCHEC de la sauvegarde des fichiers', + body_html: `

Échec de la sauvegarde des fichiers

+

La sauvegarde planifiée des fichiers a échoué et nécessite une investigation.

+`, + body_text: `Échec de la sauvegarde des fichiers\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Version update notifications + // ──────────────────────────────────────────────────────────────── + version_update_available: { + nl: { + subject: 'PicPeak-update beschikbaar: versie {{new_version}}', + body_html: `

Nieuwe PicPeak-versie beschikbaar

+

Er is een nieuwe versie van PicPeak beschikbaar.

+ +

Release-notities bekijken

`, + body_text: `Nieuwe PicPeak-versie beschikbaar\n\nHuidige versie: {{current_version}}\nNieuwe versie: {{new_version}}\nReleasekanaal: {{channel}}\n\nRelease-notities: {{release_url}}`, + }, + pt: { + subject: 'Atualização do PicPeak disponível: versão {{new_version}}', + body_html: `

Nova versão do PicPeak disponível

+

Uma nova versão do PicPeak está disponível.

+ +

Ver notas da versão

`, + body_text: `Nova versão do PicPeak disponível\n\nVersão atual: {{current_version}}\nNova versão: {{new_version}}\nCanal: {{channel}}\n\nNotas da versão: {{release_url}}`, + }, + ru: { + subject: 'Доступно обновление PicPeak: версия {{new_version}}', + body_html: `

Доступна новая версия PicPeak

+

Появилась новая версия PicPeak.

+ +

Посмотреть примечания к выпуску

`, + body_text: `Доступна новая версия PicPeak\n\nТекущая версия: {{current_version}}\nНовая версия: {{new_version}}\nКанал: {{channel}}\n\nПримечания к выпуску: {{release_url}}`, + }, + fr: { + subject: 'Mise à jour PicPeak disponible : version {{new_version}}', + body_html: `

Nouvelle version de PicPeak disponible

+

Une nouvelle version de PicPeak est disponible.

+ +

Voir les notes de version

`, + body_text: `Nouvelle version de PicPeak disponible\n\nVersion actuelle : {{current_version}}\nNouvelle version : {{new_version}}\nCanal : {{channel}}\n\nNotes de version : {{release_url}}`, + }, + }, + + // version_update_test was seeded by migration 087 in the legacy + // subject_en / body_html_en / subject_de / body_html_de columns + // AFTER migration 075 had already migrated existing rows into + // email_template_translations — so this template has zero + // translation rows even though the EN/DE content exists. The + // Templates admin UI consequently shows it as empty. Seeding the + // full set here (mirroring 087's curated EN/DE plus AI-generated + // nl/pt/ru/fr) restores the editor. + version_update_test: { + en: { + subject: '[TEST] PicPeak Update Notification — configuration check', + body_html: `

This is a test email

+

You are receiving this message because an administrator clicked +Send Test Email on the Update Notifications page of your +PicPeak installation.

+
+

Installed version: {{current_version}}

+

Channel: {{channel}}

+

Recipient address: {{recipient_email}}

+
+

If you can read this email, your SMTP configuration and the recipient +list are working correctly. When a real new version becomes available, +PicPeak will send a separate notification with release notes and update +instructions.

+

No action is required. +You may safely delete this message.

`, + body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`, + }, + de: { + subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung', + body_html: `

Dies ist eine Test-E-Mail

+

Sie erhalten diese Nachricht, weil ein Administrator auf der Seite +„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf +Test-E-Mail senden geklickt hat.

+
+

Installierte Version: {{current_version}}

+

Kanal: {{channel}}

+

Empfänger-Adresse: {{recipient_email}}

+
+

Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration +und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar +ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen +und Update-Anweisungen.

+

Es ist keine Aktion +erforderlich. Sie können diese Nachricht gefahrlos löschen.

`, + body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`, + }, + nl: { + subject: '[TEST] PicPeak-update-melding — configuratiecontrole', + body_html: `

Dit is een test-e-mail

+

U ontvangt dit bericht omdat een beheerder op de pagina +"Update-meldingen" van uw PicPeak-installatie op +Test-e-mail verzenden heeft geklikt.

+
+

Geïnstalleerde versie: {{current_version}}

+

Kanaal: {{channel}}

+

Ontvangeradres: {{recipient_email}}

+
+

Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.

+

Geen actie vereist. U kunt dit bericht veilig verwijderen.

`, + body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`, + }, + pt: { + subject: '[TESTE] Notificação de atualização do PicPeak — verificação', + body_html: `

Este é um e-mail de teste

+

Você está recebendo esta mensagem porque um administrador clicou em +Enviar e-mail de teste na página "Notificações de +atualização" da sua instalação do PicPeak.

+
+

Versão instalada: {{current_version}}

+

Canal: {{channel}}

+

Endereço do destinatário: {{recipient_email}}

+
+

Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.

+

Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.

`, + body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`, + }, + ru: { + subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка', + body_html: `

Это тестовое письмо

+

Вы получили это сообщение, потому что администратор нажал +Отправить тестовое письмо на странице +«Уведомления об обновлениях» вашей установки PicPeak.

+
+

Установленная версия: {{current_version}}

+

Канал: {{channel}}

+

Адрес получателя: {{recipient_email}}

+
+

Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.

+

Никаких действий не требуется. Можете безопасно удалить это сообщение.

`, + body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`, + }, + fr: { + subject: '[TEST] Notification de mise à jour PicPeak — vérification', + body_html: `

Ceci est un e-mail de test

+

Vous recevez ce message parce qu'un administrateur a cliqué sur +Envoyer un e-mail de test sur la page « Notifications +de mise à jour » de votre installation PicPeak.

+
+

Version installée : {{current_version}}

+

Canal : {{channel}}

+

Adresse du destinataire : {{recipient_email}}

+
+

Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.

+

Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.

`, + body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`, + }, + }, +}; + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + if (!(await knex.schema.hasTable('email_template_translations'))) return; + + // Resolve template_key → id once, skip keys that aren't seeded on + // this install (e.g. customer_invitation on a pre-090 instance). + const rows = await knex('email_templates') + .whereIn('template_key', Object.keys(TRANSLATIONS)) + .select('id', 'template_key'); + const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id])); + + let inserted = 0; + let skipped = 0; + + for (const [key, perLocale] of Object.entries(TRANSLATIONS)) { + const templateId = keyToId[key]; + if (!templateId) { + // Template not present on this install (older release than the + // seeding migration). Skip — there's nothing to attach to. + continue; + } + + for (const [language, content] of Object.entries(perLocale)) { + const existing = await knex('email_template_translations') + .where({ template_id: templateId, language }) + .first(); + if (existing) { + skipped += 1; + continue; + } + 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(), + }); + inserted += 1; + } + } + + console.log(`099_seed_missing_email_template_translations: inserted=${inserted}, skipped=${skipped}`); +}; + +exports.down = async function(knex) { + // Down-migration intentionally a no-op. We don't know which of the + // locale rows existed before this migration vs were inserted by it — + // dropping every nl/pt/ru/fr row would wipe content the admin may + // have edited in the UI. Rollback by hand if you really need to. +}; diff --git a/backend/migrations/core/100_backfill_email_template_subcategory.js b/backend/migrations/core/100_backfill_email_template_subcategory.js new file mode 100644 index 00000000..bc8e7b56 --- /dev/null +++ b/backend/migrations/core/100_backfill_email_template_subcategory.js @@ -0,0 +1,295 @@ +/** + * Migration: Re-apply email-template backfills that earlier deployed + * versions of 098 / 099 missed. + * + * Why a separate migration? Knex tracks migrations by filename — once + * 098 / 099 were recorded as applied in `knex_migrations`, editing + * them doesn't re-run on subsequent deploys. The first deployed + * versions of those migrations didn't include: + * - the `subcategory` column population (added later) + * - the `customer_password_reset` category override (added later) + * - the en/de/nl/pt/ru/fr translation rows for + * `customer_password_reset` and `version_update_test` (both + * inserted after 075 ran, so they sat in legacy columns only + * and showed empty in the Templates editor — the AI translations + * were added to 099 after its first deploy). + * + * This migration is append-only (no schema change beyond defensive + * column checks) and re-applies all the affected data: + * 1. Sets category / subcategory / feature_flag on every known + * template_key. + * 2. Seeds missing translation rows for the two post-075 + * templates across all six locales. + * + * Idempotent throughout: skips translation inserts that already + * exist, and the category writes are no-ops when values already match. + */ + +const TEMPLATE_METADATA = { + // 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 }, + // Customers — customer-portal lifecycle. + customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, + customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, +}; + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + // Defensive: if migration 098 didn't run for some reason on this + // install (a fork, a partial copy, etc.) make sure the columns + // exist before we try to write to them. Idempotent — these are + // no-ops if the column already exists. + const cols = await knex('email_templates').columnInfo(); + if (!cols.category) { + await knex.schema.alterTable('email_templates', (t) => { + t.string('category', 32).notNullable().defaultTo('core'); + }); + } + if (!cols.subcategory) { + await knex.schema.alterTable('email_templates', (t) => { + t.string('subcategory', 32).nullable(); + }); + } + if (!cols.feature_flag) { + await knex.schema.alterTable('email_templates', (t) => { + t.string('feature_flag', 64).nullable(); + }); + } + + let updated = 0; + for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) { + const result = await knex('email_templates') + .where({ template_key: key }) + .update({ + category: meta.category, + subcategory: meta.subcategory, + feature_flag: meta.feature_flag, + }); + if (result > 0) updated += 1; + } + console.log(`100_backfill_email_template_subcategory: updated ${updated} template rows`); + + // ── Translation backfill ────────────────────────────────────────── + // customer_password_reset (migration 092) and version_update_test + // (migration 087) were inserted AFTER migration 075 ran, so they + // have content in legacy subject_*/body_html_* columns but zero + // rows in `email_template_translations`. The Templates editor + // reads exclusively from the translations table → shows them as + // 0/6 empty until we seed them. Migration 099 added these rows on + // initial deploy, but the earlier-shipped version of 099 didn't + // include them, so instances that ran it then-and-now still have + // empty editors. Re-seed defensively here, skipping any + // (template_id, language) pair that already exists. + if (!(await knex.schema.hasTable('email_template_translations'))) return; + + const TRANSLATIONS = { + customer_password_reset: { + en: { + subject: 'Reset your customer account password', + body_html: `

Hello,

+

Your photographer has triggered a password reset for your customer account.

+

Set a new password

+

This link expires on {{expires_at}}.

+

If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.

`, + body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`, + }, + de: { + subject: 'Passwort für dein Kundenkonto zurücksetzen', + body_html: `

Hallo,

+

Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.

+

Neues Passwort festlegen

+

Dieser Link läuft am {{expires_at}} ab.

+

Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.

`, + body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`, + }, + nl: { + subject: 'Wachtwoord van uw klantaccount opnieuw instellen', + body_html: `

Hallo,

+

Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.

+

Nieuw wachtwoord instellen

+

Deze link verloopt op {{expires_at}}.

+

Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.

`, + body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`, + }, + pt: { + subject: 'Redefina a senha da sua conta de cliente', + body_html: `

Olá,

+

Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.

+

Definir nova senha

+

Este link expira em {{expires_at}}.

+

Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.

`, + body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`, + }, + ru: { + subject: 'Сброс пароля вашей клиентской учётной записи', + body_html: `

Здравствуйте!

+

Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.

+

Задать новый пароль

+

Срок действия ссылки истекает {{expires_at}}.

+

Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.

`, + body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`, + }, + fr: { + subject: 'Réinitialisez le mot de passe de votre compte client', + body_html: `

Bonjour,

+

Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.

+

Définir un nouveau mot de passe

+

Ce lien expire le {{expires_at}}.

+

Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.

`, + body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`, + }, + }, + version_update_test: { + en: { + subject: '[TEST] PicPeak Update Notification — configuration check', + body_html: `

This is a test email

+

You are receiving this message because an administrator clicked +Send Test Email on the Update Notifications page of your +PicPeak installation.

+
+

Installed version: {{current_version}}

+

Channel: {{channel}}

+

Recipient address: {{recipient_email}}

+
+

If you can read this email, your SMTP configuration and the recipient +list are working correctly. When a real new version becomes available, +PicPeak will send a separate notification with release notes and update +instructions.

+

No action is required. +You may safely delete this message.

`, + body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`, + }, + de: { + subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung', + body_html: `

Dies ist eine Test-E-Mail

+

Sie erhalten diese Nachricht, weil ein Administrator auf der Seite +„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf +Test-E-Mail senden geklickt hat.

+
+

Installierte Version: {{current_version}}

+

Kanal: {{channel}}

+

Empfänger-Adresse: {{recipient_email}}

+
+

Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration +und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar +ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen +und Update-Anweisungen.

+

Es ist keine Aktion +erforderlich. Sie können diese Nachricht gefahrlos löschen.

`, + body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`, + }, + nl: { + subject: '[TEST] PicPeak-update-melding — configuratiecontrole', + body_html: `

Dit is een test-e-mail

+

U ontvangt dit bericht omdat een beheerder op de pagina +"Update-meldingen" van uw PicPeak-installatie op +Test-e-mail verzenden heeft geklikt.

+
+

Geïnstalleerde versie: {{current_version}}

+

Kanaal: {{channel}}

+

Ontvangeradres: {{recipient_email}}

+
+

Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.

+

Geen actie vereist. U kunt dit bericht veilig verwijderen.

`, + body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`, + }, + pt: { + subject: '[TESTE] Notificação de atualização do PicPeak — verificação', + body_html: `

Este é um e-mail de teste

+

Você está recebendo esta mensagem porque um administrador clicou em +Enviar e-mail de teste na página "Notificações de +atualização" da sua instalação do PicPeak.

+
+

Versão instalada: {{current_version}}

+

Canal: {{channel}}

+

Endereço do destinatário: {{recipient_email}}

+
+

Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.

+

Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.

`, + body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`, + }, + ru: { + subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка', + body_html: `

Это тестовое письмо

+

Вы получили это сообщение, потому что администратор нажал +Отправить тестовое письмо на странице +«Уведомления об обновлениях» вашей установки PicPeak.

+
+

Установленная версия: {{current_version}}

+

Канал: {{channel}}

+

Адрес получателя: {{recipient_email}}

+
+

Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.

+

Никаких действий не требуется. Можете безопасно удалить это сообщение.

`, + body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`, + }, + fr: { + subject: '[TEST] Notification de mise à jour PicPeak — vérification', + body_html: `

Ceci est un e-mail de test

+

Vous recevez ce message parce qu'un administrateur a cliqué sur +Envoyer un e-mail de test sur la page « Notifications +de mise à jour » de votre installation PicPeak.

+
+

Version installée : {{current_version}}

+

Canal : {{channel}}

+

Adresse du destinataire : {{recipient_email}}

+
+

Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.

+

Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.

`, + body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`, + }, + }, + }; + + const keyRows = await knex('email_templates') + .whereIn('template_key', Object.keys(TRANSLATIONS)) + .select('id', 'template_key'); + const keyToId = Object.fromEntries(keyRows.map((r) => [r.template_key, r.id])); + + let inserted = 0; + let skipped = 0; + for (const [key, perLocale] of Object.entries(TRANSLATIONS)) { + const templateId = keyToId[key]; + if (!templateId) continue; + for (const [language, content] of Object.entries(perLocale)) { + const existing = await knex('email_template_translations') + .where({ template_id: templateId, language }) + .first(); + if (existing) { skipped += 1; continue; } + 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(), + }); + inserted += 1; + } + } + console.log(`100_backfill_email_template_subcategory: translation rows inserted=${inserted}, skipped=${skipped}`); +}; + +exports.down = async function() { + // No-op. This migration is a data backfill — rolling it back would + // require restoring the previous values, which we don't track. + // Migration 098's down handler still owns dropping the columns. +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 19b236c5..29064649 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -315,6 +315,13 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req, template_key: template.template_key, variables: parseVariables(template), translations, + // Categorisation + feature-flag link added by migration 098. + // Older installs that haven't run the migration yet return + // '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, }); } @@ -344,6 +351,10 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async template_key: template.template_key, variables: parseVariables(template), 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, }); } catch (error) { diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2f3f3cda..24e0e666 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2226,7 +2226,23 @@ "mutedTextColor": "Fußzeilentext", "mutedTextColorHelp": "Fußzeilentext und Copyright-Zeile. Entspricht Branding → Sekundärer Text.", "buttonTextColor": "Button-Text", - "buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß." + "buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß.", + "categories": { + "core": "Kern", + "customers": "Kunden", + "calendar": "Kalender", + "quotes": "Angebote", + "billing": "Rechnungen" + }, + "featureOff": "Funktion aus", + "featureOffTooltip": "Die zugehörige Funktion ist derzeit deaktiviert. Du kannst die Vorlage trotzdem bearbeiten — sie wird verwendet, sobald die Funktion wieder aktiviert ist.", + "subcategories": { + "gallery": "Galerien", + "admin": "Admin-Konten", + "backup": "Backup & Wiederherstellung", + "system": "System-Updates", + "other": "Sonstige" + } }, "cms": { "title": "CMS-Seiten", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 079b5644..0749bd25 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1880,7 +1880,23 @@ "mutedTextColor": "Footer text", "mutedTextColorHelp": "Footer text and copyright line. Maps to Branding → Secondary text.", "buttonTextColor": "Button text", - "buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white." + "buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white.", + "categories": { + "core": "Core", + "customers": "Customers", + "calendar": "Calendar", + "quotes": "Quotes", + "billing": "Billing" + }, + "featureOff": "Feature off", + "featureOffTooltip": "The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled.", + "subcategories": { + "gallery": "Galleries", + "admin": "Admin accounts", + "backup": "Backup & restore", + "system": "System updates", + "other": "Other" + } }, "cms": { "title": "CMS Pages", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index fe92cd25..adb3d696 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1826,6 +1826,22 @@ "enterUrl": "Saisir une URL…", "addLink": "Ajouter", "cancel": "Annuler" + }, + "categories": { + "core": "Principal", + "customers": "Clients", + "calendar": "Calendrier", + "quotes": "Devis", + "billing": "Facturation" + }, + "featureOff": "Fonction désactivée", + "featureOffTooltip": "La fonctionnalité à laquelle appartient ce modèle est actuellement désactivée. Vous pouvez toujours modifier le modèle — il sera utilisé une fois la fonctionnalité réactivée.", + "subcategories": { + "gallery": "Galeries", + "admin": "Comptes administrateurs", + "backup": "Sauvegarde & restauration", + "system": "Mises à jour système", + "other": "Autres" } }, "cms": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 01af8d6b..cd2e67c9 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1880,7 +1880,23 @@ "mutedTextColor": "Voetteksttekst", "mutedTextColorHelp": "Voetteksttekst en copyrightregel. Komt overeen met Huisstijl → Secundaire tekst.", "buttonTextColor": "Knoptekst", - "buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit." + "buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit.", + "categories": { + "core": "Kern", + "customers": "Klanten", + "calendar": "Agenda", + "quotes": "Offertes", + "billing": "Facturatie" + }, + "featureOff": "Functie uit", + "featureOffTooltip": "De functie waar deze sjabloon bij hoort, is momenteel uitgeschakeld. U kunt de sjabloon nog steeds bewerken — deze wordt gebruikt zodra de functie weer is ingeschakeld.", + "subcategories": { + "gallery": "Galerijen", + "admin": "Adminaccounts", + "backup": "Back-up & herstel", + "system": "Systeemupdates", + "other": "Overige" + } }, "cms": { "title": "CMS-pagina's", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 80268c69..ac104c16 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -1905,7 +1905,23 @@ "mutedTextColor": "Texto do rodapé", "mutedTextColorHelp": "Texto do rodapé e linha de copyright. Corresponde a Identidade Visual → Texto secundário.", "buttonTextColor": "Texto dos botões", - "buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco." + "buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco.", + "categories": { + "core": "Principal", + "customers": "Clientes", + "calendar": "Calendário", + "quotes": "Orçamentos", + "billing": "Faturamento" + }, + "featureOff": "Recurso desativado", + "featureOffTooltip": "O recurso ao qual este modelo pertence está atualmente desativado. Você ainda pode editar o modelo — ele será usado assim que o recurso for reativado.", + "subcategories": { + "gallery": "Galerias", + "admin": "Contas de administrador", + "backup": "Backup & restauração", + "system": "Atualizações do sistema", + "other": "Outros" + } }, "cms": { "title": "Páginas CMS", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 6a157dad..666e72c3 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1930,7 +1930,23 @@ "mutedTextColor": "Текст подвала", "mutedTextColorHelp": "Текст подвала и строка авторского права. Соответствует Брендинг → Дополнительный текст.", "buttonTextColor": "Текст кнопок", - "buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый." + "buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый.", + "categories": { + "core": "Основное", + "customers": "Клиенты", + "calendar": "Календарь", + "quotes": "Сметы", + "billing": "Счета" + }, + "featureOff": "Функция выключена", + "featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции.", + "subcategories": { + "gallery": "Галереи", + "admin": "Учётные записи администраторов", + "backup": "Резервное копирование и восстановление", + "system": "Системные обновления", + "other": "Прочее" + } }, "cms": { "title": "Страницы CMS", diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index e7d06a6b..cdba818f 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -24,6 +24,37 @@ import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateT import { settingsService } from '../../services/settings.service'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx"; +import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; + +/** + * Template categorisation (migration 098). Sidebar sections render + * in this order. Empty categories are hidden automatically. New + * categories: add the key here, give it an i18n label + * (email.categories.), set `feature_flag` on templates that + * should chip out when the matching flag is off. No other UI + * changes required. + */ +const CATEGORY_ORDER: readonly string[] = [ + 'core', + 'customers', + 'calendar', + 'quotes', + 'billing', +] as const; + +/** + * Sub-categorisation inside `core` (which carries 14 templates and + * deserves its own internal headers). Order is the render sequence. + * Templates whose subcategory isn't in this list fall through to a + * trailing "other" bucket so a forward-compat row never disappears. + * Other top-level categories are flat (no sub-sections) for now. + */ +const CORE_SUBCATEGORY_ORDER: readonly string[] = [ + 'gallery', + 'admin', + 'backup', + 'system', +] as const; const defaultTemplateKeys = [ { @@ -117,6 +148,7 @@ export const EmailConfigPage: React.FC = () => { const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666'); const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff'); const queryClient = useQueryClient(); + const { flags: featureFlags } = useFeatureFlags(); // SMTP Configuration state const [smtpConfig, setSmtpConfig] = useState({ @@ -714,11 +746,36 @@ export const EmailConfigPage: React.FC = () => {

{t('email.templates')}

-
- {templates.map(template => { - const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key); + {/* Templates grouped by category (migration 098). Categories + in CATEGORY_ORDER render in sequence; templates that + report an unrecognised category fall into 'core' so a + forward-compat row never disappears from the UI. + Empty categories are hidden — admins don't see a + section header with no body. Templates whose + feature_flag is currently false stay fully visible and + editable, just chip-tagged so the admin knows the + feature is dormant. */} + {(() => { + // 1. Bucket templates by top-level category (forward-compat: + // unknown categories fall into 'core'). + const byCategory: Record = {}; + for (const template of templates) { + const cat = CATEGORY_ORDER.includes(template.category || 'core') + ? (template.category || 'core') + : 'core'; + (byCategory[cat] = byCategory[cat] || []).push(template); + } + const visibleCategories = CATEGORY_ORDER.filter((c) => byCategory[c]?.length); + + // 2. Renders a single template button. Pulled out so the + // flat path and the sub-category path share it. + const renderTemplate = (template: EmailTemplate) => { + const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key); const translationCount = getTranslationCount(template); const enTranslation = template.translations?.en; + const featureOff = template.feature_flag + ? featureFlags[template.feature_flag as FeatureKey] === false + : false; return ( ); - })} -
+ }; + + return ( +
+ {visibleCategories.map((category) => { + // Inside 'core' we group templates further by + // subcategory so the busy bucket reads cleanly. + // Other categories render their templates flat. + if (category === 'core') { + const bySub: Record = {}; + for (const template of byCategory.core) { + const sub = CORE_SUBCATEGORY_ORDER.includes(template.subcategory || '') + ? (template.subcategory as string) + : 'other'; + (bySub[sub] = bySub[sub] || []).push(template); + } + const visibleSubs = [ + ...CORE_SUBCATEGORY_ORDER.filter((s) => bySub[s]?.length), + ...(bySub.other?.length ? ['other'] : []), + ]; + return ( +
+

+ {t(`email.categories.${category}`, category)} +

+
+ {visibleSubs.map((sub) => ( +
+
+ {t(`email.subcategories.${sub}`, sub)} +
+
+ {bySub[sub].map(renderTemplate)} +
+
+ ))} +
+
+ ); + } + return ( +
+

+ {t(`email.categories.${category}`, category)} +

+
+ {byCategory[category].map(renderTemplate)} +
+
+ ); + })} +
+ ); + })()}
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index dc88b47d..66f7ac17 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -17,11 +17,37 @@ export interface EmailTemplateTranslation { body_text?: string; } +/** + * 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; variables: string[]; 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 + * active. Migration 098 backfills these. + */ + feature_flag?: string | null; updated_at: string; }