fix(email): repair and seed the gallery lifecycle templates
Correction: the reported premise held for only one of the three templates,
verified by running the core migration set against an empty database.
- expiration_warning is German-is-English on every fresh install, exactly as
reported. Repaired with migration 194's pattern verbatim.
- gallery_expired and archive_complete are NOT German-is-English -- they do
not exist at all. Their master rows are inserted only by migrations/legacy/
010+020, which never run on a fresh install, so 075/099/106/108 seeded zero
translations for them (they key off a master row that is not there). A
fresh install's email_templates holds 17 keys and neither is among them.
The consequence is worse than a translation gap: expirationChecker's
sendGalleryExpiredEmails and archiveService's completion mail both hit
"Email template not found", retry three times and die silently in
email_queue on every expiry and every archive.
So 195 also seeds those two (master row + en/de translations + category),
but only when the master row is absent -- it never overwrites. English
follows legacy 028, which emailProcessor's own comments call the shipped
copy; German follows legacy 026's wording. Both are restructured into the
plain unstyled shape the other core-seeded templates use, so wrapEmailHtml's
configurable palette governs styling rather than hard-coded hex. The
support-contact line is wrapped in {{#if support_email}} because
getSupportEmail() can return ''.
196 adds the {{#if welcome_message}} block that nl/pt/ru/fr/es/sl already
have in gallery_created but en and de lack, so the photographer's personal
note was silently dropped for those two locales even though the value is
passed at send time. safeTemplateReplace does resolve {{#if}} before variable
substitution, so this is a real conditional -- there is a test rendering the
migrated body both ways. HTML body only, matching the other locales:
emailProcessor rewrites welcome_message through formatWelcomeMessage
(escape + nl2br) once for both bodies, so the text part would print literal
<br /> and &.
Both migrations keep 194's conservative condition -- rewrite only while the
German is still byte-identical to English or empty -- so admin-edited and
legacy-translated installs are untouched. Idempotent, guarded, no-op down().
Known gap, documented in 195's header: the two newly seeded templates get
en/de only. nl/pt/ru/fr/es/sl fall back to en via processTemplate's fallback
chain, which is strictly better than today's hard failure but is not real
localisation.
Refs testplan REPORT.md B1, B2.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Migration 195 covers the three gallery-lifecycle mails migration 194 left
|
||||
* behind, and they turned out to be broken in two different ways:
|
||||
*
|
||||
* - `expiration_warning` ships German-that-is-English on every fresh install
|
||||
* (059 copies `_en` into `_de`, 075 materialises that as the `de` row, and
|
||||
* the real German only ever lived in legacy migration 026).
|
||||
* - `gallery_expired` / `archive_complete` are not seeded by any core
|
||||
* migration at all, so a fresh install has no row for them and both mail
|
||||
* paths fail with "Email template '<key>' not found".
|
||||
*
|
||||
* What is pinned here is as much about restraint as repair: the repair path may
|
||||
* only overwrite a German row that is still the English one, and the seed path
|
||||
* may only insert when the row is absent.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/195_german_gallery_lifecycle_templates');
|
||||
|
||||
// The English copy of expiration_warning seeded by migration 001, verbatim.
|
||||
const WARN_EN_SUBJECT = 'Your Photo Gallery Expires Soon';
|
||||
const WARN_EN_HTML = `<h2>Gallery Expiring Soon</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p>
|
||||
<p>After expiration, the gallery will be archived and no longer accessible to guests.</p>
|
||||
<p><a href="{{gallery_link}}">Visit Gallery</a></p>`;
|
||||
const WARN_EN_TEXT = 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.';
|
||||
const WARN_VARIABLES = ['host_name', 'event_name', 'days_remaining', 'gallery_link'];
|
||||
|
||||
// Captures both plain {{var}} tokens and the {{#if var}} / {{/if}} pair, so an
|
||||
// unbalanced or renamed conditional shows up as a placeholder-set mismatch.
|
||||
const placeholdersOf = (...parts) => {
|
||||
const found = new Set();
|
||||
for (const part of parts) {
|
||||
for (const match of String(part || '').matchAll(/\{\{\s*(#if\s+[\w.]+|\/if|[\w.]+)\s*\}\}/g)) {
|
||||
found.add(match[1].replace(/\s+/g, ' '));
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
};
|
||||
|
||||
describe('migration 195 — German + seeding for the remaining gallery-lifecycle mails', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig195-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const createTables = async () => {
|
||||
await knex.schema.createTable('email_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('template_key').unique().notNullable();
|
||||
t.string('subject_en');
|
||||
t.string('subject_de');
|
||||
t.text('body_html_en');
|
||||
t.text('body_html_de');
|
||||
t.text('body_text_en');
|
||||
t.text('body_text_de');
|
||||
t.json('variables');
|
||||
t.string('category');
|
||||
t.string('subcategory');
|
||||
t.string('feature_flag');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await knex.schema.createTable('email_template_translations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('template_id');
|
||||
t.string('language', 10);
|
||||
t.text('subject');
|
||||
t.text('body_html');
|
||||
t.text('body_text');
|
||||
t.string('created_at');
|
||||
t.string('updated_at');
|
||||
});
|
||||
};
|
||||
|
||||
const dropTables = async () => {
|
||||
if (await knex.schema.hasTable('email_template_translations')) {
|
||||
await knex.schema.dropTable('email_template_translations');
|
||||
}
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
await knex.schema.dropTable('email_templates');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The state a fresh install lands in: expiration_warning exists with the
|
||||
* English copy in BOTH the `_en` and `_de` columns (and both translation
|
||||
* rows); gallery_expired / archive_complete do not exist at all.
|
||||
*/
|
||||
const seedFreshInstall = async ({ deHtml = WARN_EN_HTML, deSubject = WARN_EN_SUBJECT, deText = WARN_EN_TEXT } = {}) => {
|
||||
const [id] = await knex('email_templates').insert({
|
||||
template_key: 'expiration_warning',
|
||||
subject_en: WARN_EN_SUBJECT,
|
||||
subject_de: deSubject,
|
||||
body_html_en: WARN_EN_HTML,
|
||||
body_html_de: deHtml,
|
||||
body_text_en: WARN_EN_TEXT,
|
||||
body_text_de: deText,
|
||||
variables: JSON.stringify(WARN_VARIABLES),
|
||||
category: 'core',
|
||||
subcategory: 'gallery',
|
||||
});
|
||||
await knex('email_template_translations').insert([
|
||||
{ template_id: id, language: 'en', subject: WARN_EN_SUBJECT, body_html: WARN_EN_HTML, body_text: WARN_EN_TEXT },
|
||||
{ template_id: id, language: 'de', subject: deSubject, body_html: deHtml, body_text: deText },
|
||||
]);
|
||||
return id;
|
||||
};
|
||||
|
||||
const rowFor = async (templateId, language) =>
|
||||
knex('email_template_translations').where({ template_id: templateId, language }).first();
|
||||
|
||||
const masterFor = async (templateKey) =>
|
||||
knex('email_templates').where({ template_key: templateKey }).first();
|
||||
|
||||
const translationsFor = async (templateKey) => {
|
||||
const master = await masterFor(templateKey);
|
||||
return knex('email_template_translations').where({ template_id: master.id });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await dropTables();
|
||||
await createTables();
|
||||
});
|
||||
|
||||
describe('expiration_warning — repair', () => {
|
||||
it('replaces the English-as-German row with actual German', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const de = await rowFor(id, 'de');
|
||||
expect(de.subject).not.toBe(WARN_EN_SUBJECT);
|
||||
expect(de.body_html).not.toBe(WARN_EN_HTML);
|
||||
expect(de.body_text).not.toBe(WARN_EN_TEXT);
|
||||
expect(de.subject).toContain('Fotogalerie');
|
||||
expect(de.body_html).toContain('Galerie läuft bald ab');
|
||||
expect(de.body_text).toContain('Tagen ab');
|
||||
// The English row is not collateral damage.
|
||||
expect((await rowFor(id, 'en')).body_html).toBe(WARN_EN_HTML);
|
||||
});
|
||||
|
||||
it('uses exactly the placeholder set of the English original', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const en = await rowFor(id, 'en');
|
||||
const de = await rowFor(id, 'de');
|
||||
const expected = placeholdersOf(en.subject, en.body_html, en.body_text);
|
||||
expect(expected).toEqual(['days_remaining', 'event_name', 'gallery_link', 'host_name']);
|
||||
expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected);
|
||||
// ...which is also the template's declared `variables` array.
|
||||
expect(expected).toEqual([...WARN_VARIABLES].sort());
|
||||
});
|
||||
|
||||
it('repairs the legacy _de columns too', async () => {
|
||||
await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await masterFor('expiration_warning');
|
||||
expect(master.body_html_de).not.toBe(WARN_EN_HTML);
|
||||
expect(master.body_html_de).toContain('Galerie läuft bald ab');
|
||||
expect(master.subject_de).not.toBe(WARN_EN_SUBJECT);
|
||||
expect(master.body_html_en).toBe(WARN_EN_HTML);
|
||||
});
|
||||
|
||||
it('leaves an already-translated German row (and columns) alone', async () => {
|
||||
// What a legacy install carries after legacy migration 026.
|
||||
const legacyDe = '<h2>Galerie läuft bald ab</h2><p>Liebe(r) {{host_name}},</p>';
|
||||
const id = await seedFreshInstall({
|
||||
deSubject: 'Ihre Fotogalerie läuft bald ab',
|
||||
deHtml: legacyDe,
|
||||
deText: 'Galerie läuft bald ab',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'de')).body_html).toBe(legacyDe);
|
||||
expect((await masterFor('expiration_warning')).body_html_de).toBe(legacyDe);
|
||||
});
|
||||
|
||||
it('fills in a missing German row', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
await knex('email_template_translations').where({ template_id: id, language: 'de' }).del();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'de')).body_html).toContain('Galerie läuft bald ab');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['gallery_expired', 'Galerie abgelaufen', 'Gallery Expired'],
|
||||
['archive_complete', 'Archivierung abgeschlossen', 'Archive Complete'],
|
||||
])('%s — seed', (templateKey, germanMarker, englishMarker) => {
|
||||
it('inserts the master row a fresh install never got', async () => {
|
||||
await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await masterFor(templateKey);
|
||||
expect(master).toBeDefined();
|
||||
expect(master.category).toBe('core');
|
||||
expect(master.subcategory).toBe('gallery');
|
||||
expect(JSON.parse(master.variables)).toContain('event_name');
|
||||
expect(master.body_html_en).toContain(englishMarker);
|
||||
expect(master.body_html_de).toContain(germanMarker);
|
||||
});
|
||||
|
||||
it('inserts en + de translations whose German differs from the English', async () => {
|
||||
await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await translationsFor(templateKey);
|
||||
expect(rows.map((r) => r.language).sort()).toEqual(['de', 'en']);
|
||||
const en = rows.find((r) => r.language === 'en');
|
||||
const de = rows.find((r) => r.language === 'de');
|
||||
expect(de.subject).not.toBe(en.subject);
|
||||
expect(de.body_html).not.toBe(en.body_html);
|
||||
expect(de.body_text).not.toBe(en.body_text);
|
||||
expect(de.body_html).toContain(germanMarker);
|
||||
});
|
||||
|
||||
it('uses exactly the placeholder set of the English original', async () => {
|
||||
await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const rows = await translationsFor(templateKey);
|
||||
const en = rows.find((r) => r.language === 'en');
|
||||
const de = rows.find((r) => r.language === 'de');
|
||||
const expected = placeholdersOf(en.subject, en.body_html, en.body_text);
|
||||
expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected);
|
||||
// The support-contact line is conditional on both sides — getSupportEmail()
|
||||
// returns '' when nothing is configured, and an unbalanced {{#if}}/{{/if}}
|
||||
// would leave literal markup in the rendered mail.
|
||||
expect(expected).toContain('#if support_email');
|
||||
expect(expected).toContain('/if');
|
||||
});
|
||||
|
||||
it('declares exactly the variables the send path fills', async () => {
|
||||
await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await masterFor(templateKey);
|
||||
const rows = await translationsFor(templateKey);
|
||||
const en = rows.find((r) => r.language === 'en');
|
||||
const used = placeholdersOf(en.subject, en.body_html, en.body_text)
|
||||
.filter((p) => !p.startsWith('#') && !p.startsWith('/'));
|
||||
expect(JSON.parse(master.variables).sort()).toEqual(used);
|
||||
});
|
||||
|
||||
it('never overwrites an existing row', async () => {
|
||||
// What a legacy install carries: the row exists and legacy 026 already
|
||||
// gave it real German.
|
||||
await seedFreshInstall();
|
||||
const legacyDe = `<h2>${germanMarker}</h2><p>Liebe(r) {{host_name}},</p>`;
|
||||
const [id] = await knex('email_templates').insert({
|
||||
template_key: templateKey,
|
||||
subject_en: 'admin edited',
|
||||
subject_de: 'vom Admin bearbeitet',
|
||||
body_html_en: '<p>admin edited</p>',
|
||||
body_html_de: legacyDe,
|
||||
body_text_en: 'admin edited',
|
||||
body_text_de: 'vom Admin bearbeitet',
|
||||
variables: JSON.stringify(['event_name']),
|
||||
});
|
||||
await knex('email_template_translations').insert([
|
||||
{ template_id: id, language: 'en', subject: 'admin edited', body_html: '<p>admin edited</p>', body_text: 'admin edited' },
|
||||
{ template_id: id, language: 'de', subject: 'vom Admin bearbeitet', body_html: legacyDe, body_text: 'vom Admin bearbeitet' },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await masterFor(templateKey);
|
||||
expect(master.body_html_en).toBe('<p>admin edited</p>');
|
||||
expect(master.body_html_de).toBe(legacyDe);
|
||||
const rows = await translationsFor(templateKey);
|
||||
expect(rows.find((r) => r.language === 'en').body_html).toBe('<p>admin edited</p>');
|
||||
expect(rows.find((r) => r.language === 'de').body_html).toBe(legacyDe);
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
const onceDe = await rowFor(id, 'de');
|
||||
const onceRows = await knex('email_template_translations').select('*').orderBy('id');
|
||||
await migration.up(knex);
|
||||
const twiceDe = await rowFor(id, 'de');
|
||||
const twiceRows = await knex('email_template_translations').select('*').orderBy('id');
|
||||
|
||||
expect(twiceDe.body_html).toBe(onceDe.body_html);
|
||||
expect(twiceRows.length).toBe(onceRows.length);
|
||||
expect(await knex('email_templates').count('* as c'))
|
||||
.toEqual([{ c: 3 }]);
|
||||
});
|
||||
|
||||
it('skips the repair when the template is absent, and no-ops without the tables', async () => {
|
||||
// No expiration_warning row at all — the repair must not throw, and the two
|
||||
// seeded templates still land.
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
expect(await masterFor('expiration_warning')).toBeUndefined();
|
||||
expect(await masterFor('gallery_expired')).toBeDefined();
|
||||
|
||||
await dropTables();
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await createTables();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* `welcome_message` is on the queued payload of every gallery_created mail
|
||||
* (adminEvents/crud.js:139) and nl/pt/ru/fr/es/sl all render it — but the
|
||||
* English copy seeded by migration 001 has no `{{#if welcome_message}}` block,
|
||||
* and migration 194's German was written against that English. So EN and DE
|
||||
* recipients silently lost the photographer's personal note to their client.
|
||||
*
|
||||
* The guard is byte-identity with the copy this codebase seeded: an
|
||||
* admin-edited body, and a legacy install whose EN/DE came from legacy 028/026
|
||||
* (both of which already carry a welcome_message block), must survive
|
||||
* untouched.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/196_gallery_created_welcome_message_block');
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
// The English copy seeded by migration 001, verbatim.
|
||||
const EN_SUBJECT = 'Your Photo Gallery is Ready!';
|
||||
const EN_HTML = `<h2>Gallery Created Successfully</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" has been created successfully!</p>
|
||||
<p><strong>Gallery Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Gallery Link: {{gallery_link}}</li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
<li>Expires: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Share this link and password with your guests to allow them to view and download photos.</p>`;
|
||||
const EN_TEXT = 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!';
|
||||
|
||||
// The German copy written by migration 194, verbatim.
|
||||
const DE_SUBJECT = 'Ihre Fotogalerie ist bereit!';
|
||||
const DE_HTML = `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Guten Tag {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!</p>
|
||||
<p><strong>Details zur Galerie:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Link zur Galerie: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Verfügbar bis: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>`;
|
||||
const DE_TEXT = 'Galerie erfolgreich erstellt\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!';
|
||||
|
||||
// The exact one-liner nl/pt/ru (075), fr (099), es (106) and sl (108) use.
|
||||
const SIBLING_BLOCK = '{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}';
|
||||
|
||||
const SEEDED_VARIABLES = ['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date'];
|
||||
|
||||
const placeholdersOf = (...parts) => {
|
||||
const found = new Set();
|
||||
for (const part of parts) {
|
||||
for (const match of String(part || '').matchAll(/\{\{\s*(#if\s+[\w.]+|\/if|[\w.]+)\s*\}\}/g)) {
|
||||
found.add(match[1].replace(/\s+/g, ' '));
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
};
|
||||
|
||||
describe('migration 196 — {{#if welcome_message}} for the EN/DE gallery_created copy', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig196-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const createTables = async () => {
|
||||
await knex.schema.createTable('email_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('template_key').unique().notNullable();
|
||||
t.string('subject_en');
|
||||
t.string('subject_de');
|
||||
t.text('body_html_en');
|
||||
t.text('body_html_de');
|
||||
t.text('body_text_en');
|
||||
t.text('body_text_de');
|
||||
t.json('variables');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await knex.schema.createTable('email_template_translations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('template_id');
|
||||
t.string('language', 10);
|
||||
t.text('subject');
|
||||
t.text('body_html');
|
||||
t.text('body_text');
|
||||
t.string('created_at');
|
||||
t.string('updated_at');
|
||||
});
|
||||
};
|
||||
|
||||
const dropTables = async () => {
|
||||
if (await knex.schema.hasTable('email_template_translations')) {
|
||||
await knex.schema.dropTable('email_template_translations');
|
||||
}
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
await knex.schema.dropTable('email_templates');
|
||||
}
|
||||
};
|
||||
|
||||
/** The state a fresh install lands in once migration 194 has run. */
|
||||
const seedPost194 = async ({ enHtml = EN_HTML, deHtml = DE_HTML, variables = SEEDED_VARIABLES } = {}) => {
|
||||
const [id] = await knex('email_templates').insert({
|
||||
template_key: 'gallery_created',
|
||||
subject_en: EN_SUBJECT,
|
||||
subject_de: DE_SUBJECT,
|
||||
body_html_en: enHtml,
|
||||
body_html_de: deHtml,
|
||||
body_text_en: EN_TEXT,
|
||||
body_text_de: DE_TEXT,
|
||||
variables: JSON.stringify(variables),
|
||||
});
|
||||
await knex('email_template_translations').insert([
|
||||
{ template_id: id, language: 'en', subject: EN_SUBJECT, body_html: enHtml, body_text: EN_TEXT },
|
||||
{ template_id: id, language: 'de', subject: DE_SUBJECT, body_html: deHtml, body_text: DE_TEXT },
|
||||
// A sibling locale that already had the block all along.
|
||||
{ template_id: id, language: 'nl', subject: 'Uw fotogalerij is klaar!', body_html: `<p>Beste {{host_name}},</p>\n${SIBLING_BLOCK}`, body_text: 'Beste {{host_name}},' },
|
||||
]);
|
||||
return id;
|
||||
};
|
||||
|
||||
const rowFor = async (templateId, language) =>
|
||||
knex('email_template_translations').where({ template_id: templateId, language }).first();
|
||||
|
||||
beforeEach(async () => {
|
||||
await dropTables();
|
||||
await createTables();
|
||||
});
|
||||
|
||||
it('adds the block to the English and German bodies', async () => {
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'en')).body_html).toBe(`${EN_HTML}\n${SIBLING_BLOCK}`);
|
||||
expect((await rowFor(id, 'de')).body_html).toBe(`${DE_HTML}\n${SIBLING_BLOCK}`);
|
||||
});
|
||||
|
||||
it('uses the same block shape as the sibling locales', async () => {
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const nl = await rowFor(id, 'nl');
|
||||
for (const language of ['en', 'de']) {
|
||||
const row = await rowFor(id, language);
|
||||
expect(row.body_html.endsWith(SIBLING_BLOCK)).toBe(true);
|
||||
expect(row.body_html).toContain(nl.body_html.split('\n').pop());
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the German placeholder set identical to the English', async () => {
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const en = await rowFor(id, 'en');
|
||||
const de = await rowFor(id, 'de');
|
||||
const expected = placeholdersOf(en.subject, en.body_html, en.body_text);
|
||||
expect(expected).toContain('welcome_message');
|
||||
expect(expected).toContain('#if welcome_message');
|
||||
expect(expected).toContain('/if');
|
||||
expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('repairs the legacy _de/_en columns too, and declares the variable', async () => {
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await knex('email_templates').where({ id }).first();
|
||||
expect(master.body_html_en).toBe(`${EN_HTML}\n${SIBLING_BLOCK}`);
|
||||
expect(master.body_html_de).toBe(`${DE_HTML}\n${SIBLING_BLOCK}`);
|
||||
expect(JSON.parse(master.variables)).toEqual([...SEEDED_VARIABLES, 'welcome_message']);
|
||||
});
|
||||
|
||||
it('leaves the plain-text bodies alone', async () => {
|
||||
// formatWelcomeMessage HTML-escapes and nl2br's the value once, for both
|
||||
// bodies — dropping it into the text part would print literal <br />.
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'en')).body_text).toBe(EN_TEXT);
|
||||
expect((await rowFor(id, 'de')).body_text).toBe(DE_TEXT);
|
||||
});
|
||||
|
||||
it('leaves an admin-edited body untouched', async () => {
|
||||
const edited = '<h2>Our own wording</h2><p>Hi {{host_name}}</p>';
|
||||
const id = await seedPost194({ enHtml: edited, deHtml: edited });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'en')).body_html).toBe(edited);
|
||||
expect((await rowFor(id, 'de')).body_html).toBe(edited);
|
||||
const master = await knex('email_templates').where({ id }).first();
|
||||
expect(master.body_html_en).toBe(edited);
|
||||
expect(master.body_html_de).toBe(edited);
|
||||
});
|
||||
|
||||
it('leaves a legacy install (whose copy already has the block) untouched', async () => {
|
||||
const legacyEn = '<h2>Hello {{host_name}},</h2>\n{{#if welcome_message}}\n<p>{{welcome_message}}</p>\n{{/if}}';
|
||||
const legacyDe = '<h2>Hallo {{host_name}},</h2>\n{{#if welcome_message}}\n<p>{{welcome_message}}</p>\n{{/if}}';
|
||||
const id = await seedPost194({ enHtml: legacyEn, deHtml: legacyDe });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'en')).body_html).toBe(legacyEn);
|
||||
expect((await rowFor(id, 'de')).body_html).toBe(legacyDe);
|
||||
});
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const id = await seedPost194();
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await rowFor(id, 'en');
|
||||
const onceMaster = await knex('email_templates').where({ id }).first();
|
||||
await migration.up(knex);
|
||||
const twice = await rowFor(id, 'en');
|
||||
const twiceMaster = await knex('email_templates').where({ id }).first();
|
||||
|
||||
expect(twice.body_html).toBe(once.body_html);
|
||||
expect(twiceMaster.variables).toBe(onceMaster.variables);
|
||||
expect(await knex('email_template_translations').where({ template_id: id }).count('* as c'))
|
||||
.toEqual([{ c: 3 }]);
|
||||
});
|
||||
|
||||
it('no-ops when the template or the tables are absent', async () => {
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await dropTables();
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await createTables();
|
||||
});
|
||||
|
||||
describe('the block actually renders', () => {
|
||||
it('shows the message when one is set and drops the block when it is not', async () => {
|
||||
const id = await seedPost194();
|
||||
await migration.up(knex);
|
||||
const html = (await rowFor(id, 'en')).body_html;
|
||||
|
||||
const withMessage = safeTemplateReplace(html, { welcome_message: 'See you there!' }, { escapeHtml: true });
|
||||
expect(withMessage).toContain('<em>See you there!</em>');
|
||||
expect(withMessage).not.toContain('{{#if');
|
||||
expect(withMessage).not.toContain('{{/if}}');
|
||||
|
||||
const without = safeTemplateReplace(html, { welcome_message: '' }, { escapeHtml: true });
|
||||
expect(without).not.toContain('welcome_message');
|
||||
expect(without).not.toContain('<em>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Migration 195: finish the job migration 194 started for the remaining three
|
||||
* customer-facing gallery-lifecycle mails — `expiration_warning`,
|
||||
* `gallery_expired` and `archive_complete`.
|
||||
*
|
||||
* Investigating them turned up TWO different fresh-install defects, not one:
|
||||
*
|
||||
* 1. `expiration_warning` is the exact defect 194 fixed for
|
||||
* `gallery_created`: migration 001 seeds it, 059 copies `_en` into `_de`
|
||||
* ("Copy to German as default"), 075 materialises those columns as the
|
||||
* `de` translation row, and the real German only ever existed in the
|
||||
* LEGACY migrations (026), which never run on a fresh install. Every
|
||||
* install created since then mails English to German-locale recipients
|
||||
* while nl/pt/ru/fr/es/sl are all localised.
|
||||
*
|
||||
* 2. `gallery_expired` and `archive_complete` are NOT German-is-English on a
|
||||
* fresh install — they are ABSENT. Their master rows are only ever
|
||||
* inserted by legacy migrations 010/020; no core migration seeds them.
|
||||
* Verified by running the core migration set against an empty database:
|
||||
* the resulting `email_templates` holds 17 keys and neither of these two
|
||||
* is among them. So `expirationChecker.sendGalleryExpiredEmails` and
|
||||
* `archiveService`'s completion mail both hit
|
||||
* `Email template '<key>' not found` in emailProcessor (line 759) on
|
||||
* every fresh install — the queue row then retries three times and dies
|
||||
* silently. 075/099/106/108 seeded no translations for them either,
|
||||
* because they key off a master row that does not exist.
|
||||
*
|
||||
* This migration therefore does two things, both conservative:
|
||||
*
|
||||
* - REPAIR: rewrite a German row (and the legacy `_de` columns) only while
|
||||
* it is still byte-identical to the English one, or empty. That is
|
||||
* precisely the broken state. A legacy install whose German came from
|
||||
* migration 026, or any install where an admin edited the template, is
|
||||
* left untouched.
|
||||
* - SEED: insert `gallery_expired` / `archive_complete` with EN + DE only
|
||||
* when the master row is missing entirely. Never overwrites an existing
|
||||
* row.
|
||||
*
|
||||
* The German copy follows legacy migration 026's wording — the translation
|
||||
* that was always intended — restructured to mirror the English it sits next
|
||||
* to, so the placeholder set of each German body matches its English original
|
||||
* exactly. The seeded English follows legacy 028 (which emailProcessor's own
|
||||
* comments treat as the shipped copy) but in the plain, unstyled shape the
|
||||
* other core-seeded templates use, so `wrapEmailHtml`'s configurable email
|
||||
* palette governs the styling instead of hard-coded hex values.
|
||||
*
|
||||
* Known gap, deliberately not closed here: the two seeded templates get en/de
|
||||
* only. nl/pt/ru/fr/es/sl fall back to `en` via processTemplate's fallback
|
||||
* chain, which is strictly better than today's hard failure.
|
||||
*/
|
||||
|
||||
// ── expiration_warning ────────────────────────────────────────────────
|
||||
// Placeholders mirror the English original seeded by migration 001:
|
||||
// host_name, event_name, days_remaining, gallery_link.
|
||||
|
||||
const WARNING_SUBJECT_DE = 'Ihre Fotogalerie läuft bald ab';
|
||||
|
||||
const WARNING_HTML_DE = `<h2>Galerie läuft bald ab</h2>
|
||||
<p>Guten Tag {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie „{{event_name}}“ läuft in {{days_remaining}} Tagen ab.</p>
|
||||
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich.</p>
|
||||
<p><a href="{{gallery_link}}">Galerie ansehen</a></p>`;
|
||||
|
||||
const WARNING_TEXT_DE = 'Galerie läuft bald ab\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ läuft in {{days_remaining}} Tagen ab.';
|
||||
|
||||
// ── gallery_expired ───────────────────────────────────────────────────
|
||||
// Variables filled by expirationChecker.sendGalleryExpiredEmails.
|
||||
// {{support_email}} is wrapped in a conditional because getSupportEmail()
|
||||
// returns '' when neither branding_support_email nor an SMTP from-address
|
||||
// is configured (see emailProcessor.js:110).
|
||||
|
||||
const EXPIRED_VARIABLES = ['host_name', 'event_name', 'event_date', 'expiry_date', 'support_email'];
|
||||
|
||||
const EXPIRED_EN = {
|
||||
subject: 'Your Photo Gallery Has Expired',
|
||||
body_html: `<h2>Gallery Expired</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.</p>
|
||||
<p>Your photos have been archived safely — nothing is lost.</p>
|
||||
<p><strong>Gallery Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Expired: {{expiry_date}}</li>
|
||||
</ul>
|
||||
{{#if support_email}}<p>If you need access to the archived photos, contact us at <a href="mailto:{{support_email}}">{{support_email}}</a>.</p>{{/if}}`,
|
||||
body_text: 'Gallery Expired\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.\n\nYour photos have been archived safely — nothing is lost.\n\nEvent Date: {{event_date}}\nExpired: {{expiry_date}}\n\n{{#if support_email}}If you need access to the archived photos, contact us at {{support_email}}.{{/if}}',
|
||||
};
|
||||
|
||||
const EXPIRED_DE = {
|
||||
subject: 'Ihre Fotogalerie ist abgelaufen',
|
||||
body_html: `<h2>Galerie abgelaufen</h2>
|
||||
<p>Guten Tag {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie „{{event_name}}“ ist am {{expiry_date}} abgelaufen und online nicht mehr zugänglich.</p>
|
||||
<p>Ihre Fotos wurden sicher archiviert — es geht nichts verloren.</p>
|
||||
<p><strong>Details zur Galerie:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Abgelaufen am: {{expiry_date}}</li>
|
||||
</ul>
|
||||
{{#if support_email}}<p>Wenn Sie Zugriff auf die archivierten Fotos benötigen, wenden Sie sich an <a href="mailto:{{support_email}}">{{support_email}}</a>.</p>{{/if}}`,
|
||||
body_text: 'Galerie abgelaufen\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ ist am {{expiry_date}} abgelaufen und online nicht mehr zugänglich.\n\nIhre Fotos wurden sicher archiviert — es geht nichts verloren.\n\nVeranstaltungsdatum: {{event_date}}\nAbgelaufen am: {{expiry_date}}\n\n{{#if support_email}}Wenn Sie Zugriff auf die archivierten Fotos benötigen, wenden Sie sich an {{support_email}}.{{/if}}',
|
||||
};
|
||||
|
||||
// ── archive_complete ──────────────────────────────────────────────────
|
||||
// Variables filled by archiveService. This one goes to the admin address.
|
||||
|
||||
const ARCHIVE_VARIABLES = ['host_name', 'event_name', 'event_date', 'archive_date', 'photo_count', 'archive_size', 'support_email'];
|
||||
|
||||
const ARCHIVE_EN = {
|
||||
subject: 'Archive Complete: {{event_name}}',
|
||||
body_html: `<h2>Archive Complete</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>The photo gallery "{{event_name}}" has been archived successfully.</p>
|
||||
<p><strong>Archive Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Archived: {{archive_date}}</li>
|
||||
<li>Number of Photos: {{photo_count}}</li>
|
||||
<li>Archive Size: {{archive_size}}</li>
|
||||
</ul>
|
||||
<p>The archive is stored securely and can be restored if needed.</p>
|
||||
{{#if support_email}}<p>Questions? Contact us at <a href="mailto:{{support_email}}">{{support_email}}</a>.</p>{{/if}}`,
|
||||
body_text: 'Archive Complete\n\nDear {{host_name}},\n\nThe photo gallery "{{event_name}}" has been archived successfully.\n\nEvent Date: {{event_date}}\nArchived: {{archive_date}}\nNumber of Photos: {{photo_count}}\nArchive Size: {{archive_size}}\n\nThe archive is stored securely and can be restored if needed.\n\n{{#if support_email}}Questions? Contact us at {{support_email}}.{{/if}}',
|
||||
};
|
||||
|
||||
const ARCHIVE_DE = {
|
||||
subject: 'Archivierung abgeschlossen: {{event_name}}',
|
||||
body_html: `<h2>Archivierung abgeschlossen</h2>
|
||||
<p>Guten Tag {{host_name}},</p>
|
||||
<p>Die Fotogalerie „{{event_name}}“ wurde erfolgreich archiviert.</p>
|
||||
<p><strong>Details zum Archiv:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Archiviert am: {{archive_date}}</li>
|
||||
<li>Anzahl der Fotos: {{photo_count}}</li>
|
||||
<li>Archivgröße: {{archive_size}}</li>
|
||||
</ul>
|
||||
<p>Das Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.</p>
|
||||
{{#if support_email}}<p>Fragen? Wenden Sie sich an <a href="mailto:{{support_email}}">{{support_email}}</a>.</p>{{/if}}`,
|
||||
body_text: 'Archivierung abgeschlossen\n\nGuten Tag {{host_name}},\n\nDie Fotogalerie „{{event_name}}“ wurde erfolgreich archiviert.\n\nVeranstaltungsdatum: {{event_date}}\nArchiviert am: {{archive_date}}\nAnzahl der Fotos: {{photo_count}}\nArchivgröße: {{archive_size}}\n\nDas Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.\n\n{{#if support_email}}Fragen? Wenden Sie sich an {{support_email}}.{{/if}}',
|
||||
};
|
||||
|
||||
// "Not translated yet" = empty, or still the English text.
|
||||
const isUntranslated = (german, english) => {
|
||||
const de = (german || '').trim();
|
||||
if (!de) return true;
|
||||
return de === (english || '').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the German translation row + legacy `_de` columns of an existing
|
||||
* template, but only while they are still the English copy (or empty).
|
||||
*/
|
||||
async function repairGerman(knex, templateKey, german, now) {
|
||||
const master = await knex('email_templates').where('template_key', templateKey).first();
|
||||
if (!master) return false;
|
||||
|
||||
if (await knex.schema.hasTable('email_template_translations')) {
|
||||
const enRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'en' })
|
||||
.first();
|
||||
const deRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'de' })
|
||||
.first();
|
||||
|
||||
const englishHtml = (enRow && enRow.body_html) || master.body_html_en || master.body_html || '';
|
||||
|
||||
if (!deRow) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: master.id,
|
||||
language: 'de',
|
||||
subject: german.subject,
|
||||
body_html: german.body_html,
|
||||
body_text: german.body_text,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
} else if (isUntranslated(deRow.body_html, englishHtml)) {
|
||||
await knex('email_template_translations')
|
||||
.where({ id: deRow.id })
|
||||
.update({
|
||||
subject: german.subject,
|
||||
body_html: german.body_html,
|
||||
body_text: german.body_text,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy per-language columns on the master row — still the fallback path in
|
||||
// emailProcessor.processTemplate when the translations table is unavailable.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
if (cols.body_html_de && isUntranslated(master.body_html_de, master.body_html_en)) {
|
||||
await knex('email_templates')
|
||||
.where({ id: master.id })
|
||||
.update({
|
||||
subject_de: german.subject,
|
||||
body_html_de: german.body_html,
|
||||
body_text_de: german.body_text,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a master row + en/de translations for a template that is missing
|
||||
* entirely. No-op when the row already exists — this never overwrites.
|
||||
*/
|
||||
async function seedTemplate(knex, templateKey, { en, de, variables }, now) {
|
||||
const existing = await knex('email_templates').where('template_key', templateKey).first();
|
||||
if (existing) return false;
|
||||
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const masterRow = { template_key: templateKey, variables: JSON.stringify(variables) };
|
||||
if ('category' in cols) masterRow.category = 'core';
|
||||
if ('subcategory' in cols) masterRow.subcategory = 'gallery';
|
||||
if ('feature_flag' in cols) masterRow.feature_flag = null;
|
||||
if ('created_at' in cols) masterRow.created_at = now;
|
||||
if ('updated_at' in cols) masterRow.updated_at = now;
|
||||
|
||||
// Fill whichever of the legacy subject_*/body_html_*/body_text_* columns the
|
||||
// schema still carries: German into `_de`, English everywhere else.
|
||||
for (const colName of Object.keys(cols)) {
|
||||
const source = /_de$/.test(colName) ? de : en;
|
||||
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = source.subject;
|
||||
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = source.body_html;
|
||||
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = source.body_text;
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await knex('email_templates').insert(masterRow).returning('id');
|
||||
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
if (templateId && await knex.schema.hasTable('email_template_translations')) {
|
||||
for (const [language, content] of [['en', en], ['de', de]]) {
|
||||
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: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await repairGerman(knex, 'expiration_warning', {
|
||||
subject: WARNING_SUBJECT_DE,
|
||||
body_html: WARNING_HTML_DE,
|
||||
body_text: WARNING_TEXT_DE,
|
||||
}, now);
|
||||
|
||||
for (const [key, content] of [
|
||||
['gallery_expired', { en: EXPIRED_EN, de: EXPIRED_DE, variables: EXPIRED_VARIABLES }],
|
||||
['archive_complete', { en: ARCHIVE_EN, de: ARCHIVE_DE, variables: ARCHIVE_VARIABLES }],
|
||||
]) {
|
||||
const seeded = await seedTemplate(knex, key, content, now);
|
||||
if (!seeded) {
|
||||
// Row already there (a legacy install). Repair its German the same
|
||||
// conservative way — a no-op wherever legacy 026 already translated it.
|
||||
await repairGerman(knex, key, content.de, now);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// No-op: reverting would restore English-as-German and delete templates the
|
||||
// expiry/archive mail paths depend on. Admins who want different copy can
|
||||
// edit it under Settings → Email → Templates.
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Migration 196: give the English (and German) `gallery_created` template the
|
||||
* `{{#if welcome_message}}` block every other locale already has.
|
||||
*
|
||||
* `adminEvents/crud.js:139` puts `welcome_message` on the queued mail payload
|
||||
* for every published gallery, and emailProcessor runs it through
|
||||
* `formatWelcomeMessage` (escape + nl2br) and treats it as HTML-passthrough.
|
||||
* nl/pt/ru/fr/es/sl all render it, via the identical one-liner appended to the
|
||||
* end of the HTML body (migrations 075, 099, 106, 108):
|
||||
*
|
||||
* {{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}
|
||||
*
|
||||
* The English copy seeded by migration 001 has no such block, and migration
|
||||
* 194's German was written against that English — so on a fresh install EN and
|
||||
* DE recipients silently lose the photographer's personal note to their client.
|
||||
* (Legacy installs are fine: legacy 028/026 both carry a welcome_message block.)
|
||||
*
|
||||
* `{{#if}}` is a real construct, not wishful markup: `safeTemplateReplace`
|
||||
* (emailProcessor.js:515) resolves `{{#if var}}…{{/if}}` before variable
|
||||
* substitution and drops the block when the variable is empty — so an event
|
||||
* with no welcome message renders exactly as it does today.
|
||||
*
|
||||
* HTML body only, like the other six locales — and deliberately so:
|
||||
* emailProcessor rewrites `welcome_message` through `formatWelcomeMessage`
|
||||
* (HTML-escape + nl2br) once, for both bodies, so dropping it into the plain
|
||||
* text part would print literal `<br />` and `&`.
|
||||
*
|
||||
* Conservative in the same way as 194/195: each body is rewritten only while
|
||||
* it is still byte-identical to the copy this codebase seeded, so an
|
||||
* admin-edited template is never clobbered. The declared `variables` array
|
||||
* gains `welcome_message` unconditionally-if-absent — it is not admin-editable
|
||||
* for seeded templates, and the six locales that already reference the
|
||||
* variable need it declared for the Templates preview to substitute it.
|
||||
*/
|
||||
|
||||
const WELCOME_BLOCK = '\n{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}';
|
||||
|
||||
// The English body seeded by migration 001, verbatim.
|
||||
const SEEDED_HTML_EN = `<h2>Gallery Created Successfully</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" has been created successfully!</p>
|
||||
<p><strong>Gallery Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Gallery Link: {{gallery_link}}</li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
<li>Expires: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Share this link and password with your guests to allow them to view and download photos.</p>`;
|
||||
|
||||
// The German body written by migration 194, verbatim.
|
||||
const SEEDED_HTML_DE = `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Guten Tag {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!</p>
|
||||
<p><strong>Details zur Galerie:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Link zur Galerie: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Verfügbar bis: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>`;
|
||||
|
||||
const SEEDED = { en: SEEDED_HTML_EN, de: SEEDED_HTML_DE };
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
const master = await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.first();
|
||||
if (!master) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (await knex.schema.hasTable('email_template_translations')) {
|
||||
for (const [language, seededHtml] of Object.entries(SEEDED)) {
|
||||
const row = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language })
|
||||
.first();
|
||||
if (!row || (row.body_html || '') !== seededHtml) continue;
|
||||
await knex('email_template_translations')
|
||||
.where({ id: row.id })
|
||||
.update({ body_html: seededHtml + WELCOME_BLOCK, updated_at: now });
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy per-language columns — emailProcessor's fallback path.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const columnUpdate = {};
|
||||
if (cols.body_html_en && (master.body_html_en || '') === SEEDED_HTML_EN) {
|
||||
columnUpdate.body_html_en = SEEDED_HTML_EN + WELCOME_BLOCK;
|
||||
}
|
||||
if (cols.body_html_de && (master.body_html_de || '') === SEEDED_HTML_DE) {
|
||||
columnUpdate.body_html_de = SEEDED_HTML_DE + WELCOME_BLOCK;
|
||||
}
|
||||
if (Object.keys(columnUpdate).length > 0) {
|
||||
columnUpdate.updated_at = now;
|
||||
await knex('email_templates').where({ id: master.id }).update(columnUpdate);
|
||||
}
|
||||
|
||||
// Declare the variable the template (and six other locales) now reference.
|
||||
let variables = master.variables;
|
||||
if (typeof variables === 'string') {
|
||||
try { variables = JSON.parse(variables); } catch (e) { variables = null; }
|
||||
}
|
||||
if (Array.isArray(variables) && !variables.includes('welcome_message')) {
|
||||
await knex('email_templates')
|
||||
.where({ id: master.id })
|
||||
.update({ variables: JSON.stringify([...variables, 'welcome_message']), updated_at: now });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// No-op: reverting would drop the photographer's personal note from the mail
|
||||
// again. Admins who don't want it can remove the block under
|
||||
// Settings → Email → Templates.
|
||||
};
|
||||
Reference in New Issue
Block a user