Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Migration to create email_template_translations table
|
||||
* Moves from per-column language support (subject_en, subject_de) to a
|
||||
* normalized translations table where each language is a row.
|
||||
* This allows adding new languages without schema changes.
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// 1. Create the email_template_translations table
|
||||
await knex.schema.createTable('email_template_translations', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('template_id').unsigned().notNullable()
|
||||
.references('id').inTable('email_templates').onDelete('CASCADE');
|
||||
table.string('language', 10).notNullable();
|
||||
table.text('subject');
|
||||
table.text('body_html');
|
||||
table.text('body_text');
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['template_id', 'language']);
|
||||
});
|
||||
|
||||
console.log('Created email_template_translations table');
|
||||
|
||||
// 2. Migrate existing data from email_templates columns into rows
|
||||
const templates = await knex('email_templates').select('*');
|
||||
const columnInfo = await knex('email_templates').columnInfo();
|
||||
const hasLangColumns = !!columnInfo.subject_en;
|
||||
|
||||
for (const template of templates) {
|
||||
// Extract EN translation
|
||||
const enSubject = hasLangColumns
|
||||
? (template.subject_en || template.subject || '')
|
||||
: (template.subject || '');
|
||||
const enHtml = hasLangColumns
|
||||
? (template.body_html_en || template.body_html || '')
|
||||
: (template.body_html || '');
|
||||
const enText = hasLangColumns
|
||||
? (template.body_text_en || template.body_text || '')
|
||||
: (template.body_text || '');
|
||||
|
||||
// Insert EN translation
|
||||
if (enSubject || enHtml) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language: 'en',
|
||||
subject: enSubject,
|
||||
body_html: enHtml,
|
||||
body_text: enText,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract DE translation (only if lang columns exist)
|
||||
if (hasLangColumns) {
|
||||
const deSubject = template.subject_de || '';
|
||||
const deHtml = template.body_html_de || '';
|
||||
const deText = template.body_text_de || '';
|
||||
|
||||
// Only insert if DE content differs from EN or has content
|
||||
if (deSubject || deHtml) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language: 'de',
|
||||
subject: deSubject,
|
||||
body_html: deHtml,
|
||||
body_text: deText,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migrated ${templates.length} templates to translations table`);
|
||||
|
||||
// 3. Seed NL, PT, RU translations for customer-facing templates
|
||||
// Look up template IDs
|
||||
const customerTemplates = await knex('email_templates')
|
||||
.whereIn('template_key', [
|
||||
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
|
||||
])
|
||||
.select('id', 'template_key');
|
||||
|
||||
const templateMap = {};
|
||||
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
|
||||
|
||||
const seedTranslations = [];
|
||||
|
||||
// --- gallery_created ---
|
||||
if (templateMap.gallery_created) {
|
||||
const id = templateMap.gallery_created;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij is klaar!',
|
||||
body_html: `<h2>Galerij succesvol aangemaakt</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
|
||||
<p><strong>Galerij details:</strong></p>
|
||||
<ul>
|
||||
<li>Evenementdatum: {{event_date}}</li>
|
||||
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Wachtwoord: {{gallery_password}}</li>
|
||||
<li>Verloopt op: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos está pronta!',
|
||||
body_html: `<h2>Galeria criada com sucesso</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
|
||||
<p><strong>Detalhes da galeria:</strong></p>
|
||||
<ul>
|
||||
<li>Data do evento: {{event_date}}</li>
|
||||
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Senha: {{gallery_password}}</li>
|
||||
<li>Expira em: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Ваша фотогалерея готова!',
|
||||
body_html: `<h2>Галерея успешно создана</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
|
||||
<p><strong>Детали галереи:</strong></p>
|
||||
<ul>
|
||||
<li>Дата события: {{event_date}}</li>
|
||||
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Пароль: {{gallery_password}}</li>
|
||||
<li>Срок действия: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- expiration_warning ---
|
||||
if (templateMap.expiration_warning) {
|
||||
const id = templateMap.expiration_warning;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij verloopt binnenkort',
|
||||
body_html: `<h2>Galerij verloopt binnenkort</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
|
||||
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
|
||||
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
|
||||
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos expira em breve',
|
||||
body_html: `<h2>Galeria expirando em breve</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
|
||||
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
|
||||
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
|
||||
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Срок действия вашей фотогалереи скоро истекает',
|
||||
body_html: `<h2>Срок действия галереи истекает</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
|
||||
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
|
||||
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
|
||||
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- gallery_expired ---
|
||||
if (templateMap.gallery_expired) {
|
||||
const id = templateMap.gallery_expired;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij {{event_name}} is verlopen',
|
||||
body_html: `<h2>Galerij verlopen</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
|
||||
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
|
||||
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos {{event_name}} expirou',
|
||||
body_html: `<h2>Galeria expirada</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
|
||||
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
|
||||
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Срок действия фотогалереи {{event_name}} истёк',
|
||||
body_html: `<h2>Срок действия галереи истёк</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
|
||||
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
|
||||
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- archive_complete ---
|
||||
if (templateMap.archive_complete) {
|
||||
const id = templateMap.archive_complete;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Archivering voltooid: {{event_name}}',
|
||||
body_html: `<h2>Archivering voltooid</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
|
||||
<p><strong>Archief details:</strong></p>
|
||||
<ul>
|
||||
<li>Aantal foto's: {{photo_count}}</li>
|
||||
<li>Archiefgrootte: {{archive_size}}</li>
|
||||
<li>Archiefdatum: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Arquivamento concluído: {{event_name}}',
|
||||
body_html: `<h2>Arquivamento concluído</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
|
||||
<p><strong>Detalhes do arquivo:</strong></p>
|
||||
<ul>
|
||||
<li>Número de fotos: {{photo_count}}</li>
|
||||
<li>Tamanho do arquivo: {{archive_size}}</li>
|
||||
<li>Data do arquivamento: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Архивация завершена: {{event_name}}',
|
||||
body_html: `<h2>Архивация завершена</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
|
||||
<p><strong>Детали архива:</strong></p>
|
||||
<ul>
|
||||
<li>Количество фото: {{photo_count}}</li>
|
||||
<li>Размер архива: {{archive_size}}</li>
|
||||
<li>Дата архивации: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Insert all seed translations
|
||||
const now = new Date();
|
||||
for (const trans of seedTranslations) {
|
||||
trans.created_at = now;
|
||||
trans.updated_at = now;
|
||||
await knex('email_template_translations').insert(trans);
|
||||
}
|
||||
|
||||
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('email_template_translations');
|
||||
};
|
||||
+161
-133
@@ -248,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: get translations for a template, with legacy column fallback
|
||||
async function getTemplateTranslations(templateId, template) {
|
||||
const translations = {};
|
||||
try {
|
||||
const rows = await db('email_template_translations')
|
||||
.where('template_id', templateId)
|
||||
.select('language', 'subject', 'body_html', 'body_text');
|
||||
|
||||
rows.forEach(row => {
|
||||
translations[row.language] = {
|
||||
subject: row.subject || '',
|
||||
body_html: row.body_html || '',
|
||||
body_text: row.body_text || '',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Translations table might not exist yet (pre-migration)
|
||||
// Fall back to legacy columns
|
||||
if (template.subject_en !== undefined) {
|
||||
translations.en = {
|
||||
subject: template.subject_en || '',
|
||||
body_html: template.body_html_en || '',
|
||||
body_text: template.body_text_en || '',
|
||||
};
|
||||
translations.de = {
|
||||
subject: template.subject_de || '',
|
||||
body_html: template.body_html_de || '',
|
||||
body_text: template.body_text_de || '',
|
||||
};
|
||||
} else {
|
||||
translations.en = {
|
||||
subject: template.subject || '',
|
||||
body_html: template.body_html || '',
|
||||
body_text: template.body_text || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -255,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
.select('*')
|
||||
.orderBy('template_key');
|
||||
|
||||
// Parse variables JSON and format for multi-language support
|
||||
const formattedTemplates = templates.map(template => {
|
||||
const result = {
|
||||
const formattedTemplates = [];
|
||||
for (const template of templates) {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
formattedTemplates.push({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Handle both old and new schema formats
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
result.subject_en = template.subject_en;
|
||||
result.body_html_en = template.body_html_en;
|
||||
result.body_text_en = template.body_text_en;
|
||||
result.subject_de = template.subject_de;
|
||||
result.body_html_de = template.body_html_de;
|
||||
result.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
result.subject_en = template.subject;
|
||||
result.body_html_en = template.body_html;
|
||||
result.body_text_en = template.body_text;
|
||||
result.subject_de = template.subject;
|
||||
result.body_html_de = template.body_html;
|
||||
result.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
@@ -313,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Handle both old and new schema formats
|
||||
const response = {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Check which columns exist and use them appropriately
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
response.subject_en = template.subject_en;
|
||||
response.body_html_en = template.body_html_en;
|
||||
response.body_text_en = template.body_text_en;
|
||||
response.subject_de = template.subject_de;
|
||||
response.body_html_de = template.body_html_de;
|
||||
response.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
response.subject_en = template.subject;
|
||||
response.body_html_en = template.body_html;
|
||||
response.body_text_en = template.body_text;
|
||||
response.subject_de = template.subject;
|
||||
response.body_html_de = template.body_html;
|
||||
response.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update email template
|
||||
// Update email template translations
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
subject_en, subject_de,
|
||||
body_html_en, body_html_de,
|
||||
body_text_en, body_text_de
|
||||
} = req.body;
|
||||
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
// Check which columns exist in the database
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.first();
|
||||
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Determine schema type and update accordingly
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
if (subject_en !== undefined) updateData.subject_en = subject_en;
|
||||
if (subject_de !== undefined) updateData.subject_de = subject_de;
|
||||
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
|
||||
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
|
||||
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
|
||||
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
|
||||
|
||||
// Also update basic columns if they exist
|
||||
if (template.subject !== undefined) {
|
||||
updateData.subject = subject_en || updateData.subject_en;
|
||||
updateData.body_html = body_html_en || updateData.body_html_en;
|
||||
updateData.body_text = body_text_en || updateData.body_text_en || '';
|
||||
}
|
||||
} else {
|
||||
// Old schema - only update basic columns
|
||||
if (subject_en !== undefined) {
|
||||
updateData.subject = subject_en;
|
||||
updateData.body_html = body_html_en;
|
||||
updateData.body_text = body_text_en || '';
|
||||
const { translations } = req.body;
|
||||
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
// Upsert each language translation
|
||||
for (const [language, data] of Object.entries(translations)) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
const existing = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
const row = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.update(row);
|
||||
} else {
|
||||
await db('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language,
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.update(updateData);
|
||||
// Update timestamp on parent template
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update({ updated_at: new Date() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
// Also sync legacy columns for backward compatibility
|
||||
const enData = translations.en;
|
||||
const deData = translations.de;
|
||||
const legacyUpdate = { updated_at: new Date() };
|
||||
const columnInfo = await db('email_templates').columnInfo();
|
||||
|
||||
if (enData && columnInfo.subject_en) {
|
||||
legacyUpdate.subject_en = enData.subject || '';
|
||||
legacyUpdate.body_html_en = enData.body_html || '';
|
||||
legacyUpdate.body_text_en = enData.body_text || '';
|
||||
}
|
||||
if (deData && columnInfo.subject_de) {
|
||||
legacyUpdate.subject_de = deData.subject || '';
|
||||
legacyUpdate.body_html_de = deData.body_html || '';
|
||||
legacyUpdate.body_text_de = deData.body_text || '';
|
||||
}
|
||||
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update(legacyUpdate);
|
||||
|
||||
// Log activity
|
||||
await logActivity('email_template_updated',
|
||||
{ template_key: req.params.key },
|
||||
{ template_key: req.params.key, languages: Object.keys(translations) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -449,16 +453,40 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
}
|
||||
|
||||
const { preview_data, language = 'en' } = req.body;
|
||||
|
||||
// Get the appropriate language version
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Handle backward compatibility
|
||||
let htmlContent = template[htmlField] || template.body_html || '';
|
||||
let textContent = template[textField] || template.body_text || '';
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
|
||||
// Get translation from translations table with fallback
|
||||
let translation = null;
|
||||
try {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to legacy columns
|
||||
}
|
||||
|
||||
let subject = '';
|
||||
let htmlContent = '';
|
||||
let textContent = '';
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlContent = translation.body_html || '';
|
||||
textContent = translation.body_text || '';
|
||||
} else {
|
||||
// Legacy column fallback
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject || '';
|
||||
htmlContent = template[htmlField] || template.body_html || '';
|
||||
textContent = template[textField] || template.body_text || '';
|
||||
}
|
||||
|
||||
if (preview_data) {
|
||||
const escapeHtml = (str) => String(str)
|
||||
@@ -472,7 +500,7 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
const escapedValue = escapeHtml(preview_data[key]);
|
||||
htmlContent = htmlContent.replace(regex, escapedValue);
|
||||
textContent = textContent.replace(regex, preview_data[key]); // text doesn't need HTML escaping
|
||||
textContent = textContent.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, escapeHtml(preview_data[key]));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,15 +99,22 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
// Fourth priority: Check if the email domain suggests a language
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
const domainLanguageMap = [
|
||||
{ domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
|
||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||
];
|
||||
for (const { domains, language: lang } of domainLanguageMap) {
|
||||
if (domains.some(d => domain.endsWith(d))) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
@@ -300,30 +307,73 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
// Get translation from email_template_translations table with fallback chain
|
||||
let subject = '';
|
||||
let htmlBody = '';
|
||||
let textBody = '';
|
||||
|
||||
// Fall back to non-language-specific fields for backward compatibility
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
try {
|
||||
// Try requested language first, then English, then any available
|
||||
let translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (!translation) {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlBody = translation.body_html || '';
|
||||
textBody = translation.body_text || '';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
|
||||
}
|
||||
|
||||
// Fallback to legacy column-based fields if no translation found
|
||||
if (!subject && !htmlBody) {
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject_en || template.subject || '';
|
||||
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
|
||||
textBody = template[textField] || template.body_text_en || template.body_text || '';
|
||||
}
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
const passwordSecurityI18n = {
|
||||
en: '(Not shown for security reasons)',
|
||||
de: '(Aus Sicherheitsgründen nicht angezeigt)',
|
||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||
pt: '(Não exibido por motivos de segurança)',
|
||||
ru: '(Не показано в целях безопасности)',
|
||||
};
|
||||
const noPasswordI18n = {
|
||||
en: 'No password required',
|
||||
de: 'Kein Passwort erforderlich',
|
||||
nl: 'Geen wachtwoord vereist',
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
@@ -371,6 +421,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
},
|
||||
nl: {
|
||||
label: 'Klanttoegang (Privé)',
|
||||
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
|
||||
link: 'Klanttoegang openen',
|
||||
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
|
||||
Reference in New Issue
Block a user