feat(email-templates): seed missing nl/pt/ru/fr translations

This commit is contained in:
Luca
2026-05-11 20:42:28 +02:00
parent 5ec26fc998
commit 358f7ee99e
@@ -0,0 +1,646 @@
/**
* Migration: Auto-fill missing email-template translations for nl / pt /
* ru / fr.
*
* 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.
* - All other templates (admin, customer, backup, restore, version)
* had en/de only → this migration adds nl/pt/ru/fr.
*
* The 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 and are untouched.
*
* 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: `<h2>Galerie créée avec succès</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » a été créée avec succès !</p>
<p><strong>Détails de la galerie :</strong></p>
<ul>
<li>Date de l'événement : {{event_date}}</li>
<li>Lien de la galerie : <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Mot de passe : {{gallery_password}}</li>
<li>Expire le : {{expiry_date}}</li>
</ul>
<p>Partagez ce lien et le mot de passe avec vos invités pour qu'ils puissent voir et télécharger les photos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/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: `<h2>La galerie expire bientôt</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.</p>
<p>Après l'expiration, la galerie sera archivée et ne sera plus accessible aux invités.</p>
<p><a href="{{gallery_link}}">Voir la galerie</a></p>`,
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: `<h2>Galerie archivée</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » a expiré et a été archivée.</p>
<p>Les invités ne peuvent plus accéder à la galerie. Contactez votre photographe si vous avez besoin de restaurer l'accès.</p>`,
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: `<h2>Galerie archivée avec succès</h2>
<p>La galerie photo « {{event_name}} » a été archivée.</p>
<p><strong>Détails de l'archive :</strong></p>
<ul>
<li>Taille de l'archive : {{archive_size}}</li>
<li>Nombre de photos : {{photo_count}}</li>
<li>Emplacement : {{archive_path}}</li>
</ul>`,
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: `<h2>Welkom bij PicPeak</h2>
<p>U bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.</p>
<p>Klik op de onderstaande link om uw account in te stellen:</p>
<p><a href="{{invitation_link}}" class="button">Account instellen</a></p>
<p>Deze uitnodiging verloopt op {{expires_at}}.</p>
<p>Als u deze e-mail niet verwachtte, kunt u deze gerust negeren.</p>`,
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: `<h2>Bem-vindo(a) ao PicPeak</h2>
<p>Você foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.</p>
<p>Clique no link abaixo para configurar sua conta:</p>
<p><a href="{{invitation_link}}" class="button">Configurar conta</a></p>
<p>Este convite expira em {{expires_at}}.</p>
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
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: `<h2>Добро пожаловать в PicPeak</h2>
<p>{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.</p>
<p>Перейдите по ссылке ниже, чтобы настроить учётную запись:</p>
<p><a href="{{invitation_link}}" class="button">Настроить учётную запись</a></p>
<p>Срок действия приглашения истекает {{expires_at}}.</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
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: `<h2>Bienvenue sur PicPeak</h2>
<p>{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.</p>
<p>Cliquez sur le lien ci-dessous pour configurer votre compte :</p>
<p><a href="{{invitation_link}}" class="button">Configurer le compte</a></p>
<p>Cette invitation expire le {{expires_at}}.</p>
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
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: `<h2>Wachtwoord opnieuw ingesteld</h2>
<p>Hallo {{admin_name}},</p>
<p>Uw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.</p>
<p>Klik op de onderstaande link om een nieuw wachtwoord in te stellen:</p>
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
<p>Deze link verloopt op {{expires_at}}. Heeft u deze actie niet aangevraagd? Neem dan onmiddellijk contact op met uw teambeheerder.</p>`,
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: `<h2>Senha redefinida</h2>
<p>Olá {{admin_name}},</p>
<p>Sua senha de administrador do PicPeak foi redefinida por {{reset_by}}.</p>
<p>Clique no link abaixo para definir uma nova senha:</p>
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
<p>Este link expira em {{expires_at}}. Se você não solicitou esta ação, entre em contato com o administrador da sua equipe imediatamente.</p>`,
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: `<h2>Пароль сброшен</h2>
<p>Здравствуйте, {{admin_name}}!</p>
<p>Ваш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.</p>
<p>Перейдите по ссылке ниже, чтобы задать новый пароль:</p>
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
<p>Срок действия ссылки истекает {{expires_at}}. Если вы не запрашивали это действие, немедленно свяжитесь с администратором вашей команды.</p>`,
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: `<h2>Mot de passe réinitialisé</h2>
<p>Bonjour {{admin_name}},</p>
<p>Votre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.</p>
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
<p>Ce lien expire le {{expires_at}}. Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement votre administrateur.</p>`,
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: `<h2>Welkom bij uw fotogalerijen</h2>
<p>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.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Account instellen</a>
</div>
<p>Deze uitnodiging verloopt op {{expires_at}}. Werkt de link niet? Kopieer hem en plak hem in uw browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Heeft u deze e-mail niet verwacht? U kunt deze gerust negeren.</p>`,
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: `<h2>Bem-vindo(a) às suas galerias</h2>
<p>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.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Configurar conta</a>
</div>
<p>Este convite expira em {{expires_at}}. Se o link não funcionar, copie e cole-o no navegador:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
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: `<h2>Добро пожаловать в ваши галереи</h2>
<p>Вас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи событий в одном месте — больше никаких отдельных ссылок и паролей.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Настроить учётную запись</a>
</div>
<p>Срок действия приглашения истекает {{expires_at}}. Если ссылка не работает, скопируйте её в адресную строку браузера:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
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: `<h2>Bienvenue dans vos galeries photo</h2>
<p>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.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Configurer le compte</a>
</div>
<p>Cette invitation expire le {{expires_at}}. Si le lien ne fonctionne pas, copiez-le et collez-le dans votre navigateur :</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
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}}.`,
},
},
// ────────────────────────────────────────────────────────────────
// Database backups
// ────────────────────────────────────────────────────────────────
database_backup_completed: {
nl: {
subject: '[PicPeak] Database-back-up succesvol',
body_html: `<h2>Database-back-up voltooid</h2>
<p>De geplande database-back-up is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Grootte: {{backup_size}}</li>
<li>Locatie: {{backup_path}}</li>
</ul>`,
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: `<h2>Backup do banco de dados concluído</h2>
<p>O backup agendado do banco de dados foi concluído com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Tamanho: {{backup_size}}</li>
<li>Localização: {{backup_path}}</li>
</ul>`,
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: `<h2>Резервная копия базы данных создана</h2>
<p>Запланированное резервное копирование базы данных успешно завершено.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Размер: {{backup_size}}</li>
<li>Расположение: {{backup_path}}</li>
</ul>`,
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: `<h2>Sauvegarde de la base de données terminée</h2>
<p>La sauvegarde planifiée de la base de données s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Taille : {{backup_size}}</li>
<li>Emplacement : {{backup_path}}</li>
</ul>`,
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: `<h2 style="color:#b91c1c;">Database-back-up mislukt</h2>
<p>De geplande database-back-up is mislukt en moet handmatig worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>
<p>Controleer de serverlogboeken voor meer details.</p>`,
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: `<h2 style="color:#b91c1c;">Falha no backup do banco de dados</h2>
<p>O backup agendado do banco de dados falhou e precisa de investigação manual.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>
<p>Verifique os logs do servidor para mais detalhes.</p>`,
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: `<h2 style="color:#b91c1c;">Ошибка резервного копирования базы данных</h2>
<p>Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>
<p>Проверьте журналы сервера для получения дополнительной информации.</p>`,
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: `<h2 style="color:#b91c1c;">Échec de la sauvegarde de la base de données</h2>
<p>La sauvegarde planifiée de la base de données a échoué et nécessite une investigation manuelle.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>
<p>Consultez les journaux du serveur pour plus de détails.</p>`,
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: `<h2>Database-herstel voltooid</h2>
<p>De handmatige database-herstel-operatie is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Herstelpunt: {{source_backup}}</li>
</ul>`,
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: `<h2>Restauração concluída</h2>
<p>A restauração manual do banco de dados foi concluída com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Origem: {{source_backup}}</li>
</ul>`,
body_text: `Restauração concluída\n\nHorário: {{completed_at}}\nOrigem: {{source_backup}}`,
},
ru: {
subject: '[PicPeak] Восстановление БД успешно завершено',
body_html: `<h2>Восстановление базы данных завершено</h2>
<p>Ручная операция восстановления базы данных успешно завершена.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Точка восстановления: {{source_backup}}</li>
</ul>`,
body_text: `Восстановление базы данных завершено\n\nВремя: {{completed_at}}\nТочка восстановления: {{source_backup}}`,
},
fr: {
subject: '[PicPeak] Restauration de la base de données réussie',
body_html: `<h2>Restauration de la base terminée</h2>
<p>L'opération manuelle de restauration de la base de données s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Source : {{source_backup}}</li>
</ul>`,
body_text: `Restauration terminée\n\nHeure : {{completed_at}}\nSource : {{source_backup}}`,
},
},
restore_failed: {
nl: {
subject: '[PicPeak] Database-herstel MISLUKT',
body_html: `<h2 style="color:#b91c1c;">Database-herstel mislukt</h2>
<p>De handmatige database-herstel-operatie is mislukt en moet handmatig worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>`,
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: `<h2 style="color:#b91c1c;">Falha na restauração</h2>
<p>A restauração manual do banco de dados falhou e precisa de investigação.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>`,
body_text: `Falha na restauração\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
},
ru: {
subject: '[PicPeak] ОШИБКА восстановления БД',
body_html: `<h2 style="color:#b91c1c;">Ошибка восстановления базы данных</h2>
<p>Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>`,
body_text: `Ошибка восстановления базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
},
fr: {
subject: '[PicPeak] ÉCHEC de la restauration de la base de données',
body_html: `<h2 style="color:#b91c1c;">Échec de la restauration</h2>
<p>L'opération manuelle de restauration de la base de données a échoué et nécessite une investigation.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>`,
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: `<h2>Bestandsback-up voltooid</h2>
<p>De geplande bestandsback-up is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Grootte: {{backup_size}}</li>
<li>Locatie: {{backup_path}}</li>
</ul>`,
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: `<h2>Backup de arquivos concluído</h2>
<p>O backup agendado de arquivos foi concluído com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Tamanho: {{backup_size}}</li>
<li>Localização: {{backup_path}}</li>
</ul>`,
body_text: `Backup de arquivos concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
},
ru: {
subject: '[PicPeak] Резервное копирование файлов завершено',
body_html: `<h2>Резервное копирование файлов завершено</h2>
<p>Запланированное резервное копирование файлов успешно завершено.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Размер: {{backup_size}}</li>
<li>Расположение: {{backup_path}}</li>
</ul>`,
body_text: `Резервное копирование файлов завершено\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
},
fr: {
subject: '[PicPeak] Sauvegarde des fichiers terminée',
body_html: `<h2>Sauvegarde des fichiers terminée</h2>
<p>La sauvegarde planifiée des fichiers s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Taille : {{backup_size}}</li>
<li>Emplacement : {{backup_path}}</li>
</ul>`,
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: `<h2 style="color:#b91c1c;">Bestandsback-up mislukt</h2>
<p>De geplande bestandsback-up is mislukt en moet worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>`,
body_text: `Bestandsback-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`,
},
pt: {
subject: '[PicPeak] FALHA no backup de arquivos',
body_html: `<h2 style="color:#b91c1c;">Falha no backup de arquivos</h2>
<p>O backup agendado de arquivos falhou e precisa de investigação.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>`,
body_text: `Falha no backup de arquivos\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
},
ru: {
subject: '[PicPeak] ОШИБКА резервного копирования файлов',
body_html: `<h2 style="color:#b91c1c;">Ошибка резервного копирования файлов</h2>
<p>Запланированное резервное копирование файлов завершилось с ошибкой и требует проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>`,
body_text: `Ошибка резервного копирования файлов\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
},
fr: {
subject: '[PicPeak] ÉCHEC de la sauvegarde des fichiers',
body_html: `<h2 style="color:#b91c1c;">Échec de la sauvegarde des fichiers</h2>
<p>La sauvegarde planifiée des fichiers a échoué et nécessite une investigation.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>`,
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: `<h2>Nieuwe PicPeak-versie beschikbaar</h2>
<p>Er is een nieuwe versie van PicPeak beschikbaar.</p>
<ul>
<li>Huidige versie: {{current_version}}</li>
<li>Nieuwe versie: {{new_version}}</li>
<li>Releasekanaal: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Release-notities bekijken</a></p>`,
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: `<h2>Nova versão do PicPeak disponível</h2>
<p>Uma nova versão do PicPeak está disponível.</p>
<ul>
<li>Versão atual: {{current_version}}</li>
<li>Nova versão: {{new_version}}</li>
<li>Canal: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Ver notas da versão</a></p>`,
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: `<h2>Доступна новая версия PicPeak</h2>
<p>Появилась новая версия PicPeak.</p>
<ul>
<li>Текущая версия: {{current_version}}</li>
<li>Новая версия: {{new_version}}</li>
<li>Канал выпуска: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Посмотреть примечания к выпуску</a></p>`,
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: `<h2>Nouvelle version de PicPeak disponible</h2>
<p>Une nouvelle version de PicPeak est disponible.</p>
<ul>
<li>Version actuelle : {{current_version}}</li>
<li>Nouvelle version : {{new_version}}</li>
<li>Canal : {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Voir les notes de version</a></p>`,
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: {
nl: {
subject: '[Test] PicPeak-update-meldingen werken',
body_html: `<h2>Testmelding</h2>
<p>Dit is een testmail om te bevestigen dat de PicPeak-update-meldingen correct zijn geconfigureerd.</p>
<p>Geen actie vereist.</p>`,
body_text: `Testmelding\n\nDit is een testmail om te bevestigen dat de PicPeak-update-meldingen correct zijn geconfigureerd. Geen actie vereist.`,
},
pt: {
subject: '[Teste] Notificações de atualização do PicPeak funcionando',
body_html: `<h2>Notificação de teste</h2>
<p>Este é um e-mail de teste para confirmar que as notificações de atualização do PicPeak estão configuradas corretamente.</p>
<p>Nenhuma ação é necessária.</p>`,
body_text: `Notificação de teste\n\nEste é um e-mail de teste para confirmar que as notificações de atualização do PicPeak estão configuradas corretamente. Nenhuma ação é necessária.`,
},
ru: {
subject: '[Тест] Уведомления об обновлениях PicPeak работают',
body_html: `<h2>Тестовое уведомление</h2>
<p>Это тестовое письмо для подтверждения корректной настройки уведомлений об обновлениях PicPeak.</p>
<p>Никаких действий не требуется.</p>`,
body_text: `Тестовое уведомление\n\nЭто тестовое письмо для подтверждения корректной настройки уведомлений об обновлениях PicPeak. Никаких действий не требуется.`,
},
fr: {
subject: '[Test] Les notifications de mise à jour PicPeak fonctionnent',
body_html: `<h2>Notification de test</h2>
<p>Ceci est un e-mail de test confirmant que les notifications de mise à jour PicPeak sont correctement configurées.</p>
<p>Aucune action n'est requise.</p>`,
body_text: `Notification de test\n\nCeci est un e-mail de test confirmant que les notifications de mise à jour PicPeak sont correctement configurées. Aucune 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.
};