fix(email): give gallery_created a real German translation
translations.de for gallery_created was the English copy word for word, while
nl/pt/ru/fr/es/sl are all localized. This is the mail sent on every gallery
creation, so German-default installs have been silently mailing English.
Root cause chain, fresh installs only: 001_init seeds the English template;
059 introduces the multilingual columns and fills subject_de/body_html_de/
body_text_de from their _en counterparts (its own comment: "Copy to German as
default"); 075 then materialises exactly those columns as the `de` row. The
real German only ever existed in migrations/legacy/026, and run-migrations.js
runs core/ only for fresh installs -- so every install created since 059 has
the English-as-German row.
A code-only fix would have changed nothing: knex will not re-run 059/075, so
existing installs would keep the bad row forever. Fixed as a content migration
following the repo's precedent for template repairs (094, 172).
Conservative about what it touches: the German row is rewritten only while it
is still byte-identical to English (or empty) -- precisely the broken state --
so a legacy install whose German came from 026, or any admin-edited template,
is left alone. Also repairs the legacy _de columns, which are still
emailProcessor's fallback path. Idempotent, hasTable-guarded, no-op down()
(reverting would restore English-as-German).
Placeholder parity with the English original is exact and test-asserted:
host_name, event_name, event_date, gallery_link, gallery_password, expiry_date.
Two related gaps found but deliberately not fixed, both outside the reported
bug: expiration_warning, gallery_expired and archive_complete are German-is-
English on fresh installs through the identical 059 mechanism (legacy 026
fixed all four). And nl/pt/ru/fr/es/sl additionally wrap a
{{#if welcome_message}} block that the English original lacks, even though
welcome_message is passed at send time -- so EN and now DE drop the
photographer's personal note. That is an English-side gap needing its own
decision.
Refs testplan REPORT.md #16 (Part 3, J.04).
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* The `gallery_created` German translation shipped as the English text
|
||||
* verbatim on every fresh install (QA J.04), because 059 seeds `_de` from
|
||||
* `_en` and 075 turns those columns into the `de` translation row.
|
||||
*
|
||||
* What is pinned here is as much about restraint as repair: the migration may
|
||||
* only overwrite a German row that is still the English one, so a legacy
|
||||
* install (whose German came from legacy migration 026) and any template an
|
||||
* admin has edited themselves survive untouched.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/194_german_gallery_created_translation');
|
||||
|
||||
// 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!';
|
||||
|
||||
const placeholdersOf = (...parts) => {
|
||||
const found = new Set();
|
||||
for (const part of parts) {
|
||||
for (const match of String(part || '').matchAll(/\{\{\s*([#/]?[\w.]+)\s*\}\}/g)) {
|
||||
found.add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
};
|
||||
|
||||
describe('migration 194 — German gallery_created translation (QA J.04)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig194-'));
|
||||
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: 059 copied EN into the DE columns. */
|
||||
const seedFreshInstall = async ({ deHtml = EN_HTML, deSubject = EN_SUBJECT, deText = EN_TEXT } = {}) => {
|
||||
const [id] = await knex('email_templates').insert({
|
||||
template_key: 'gallery_created',
|
||||
subject_en: EN_SUBJECT,
|
||||
subject_de: deSubject,
|
||||
body_html_en: EN_HTML,
|
||||
body_html_de: deHtml,
|
||||
body_text_en: EN_TEXT,
|
||||
body_text_de: deText,
|
||||
variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']),
|
||||
});
|
||||
await knex('email_template_translations').insert([
|
||||
{ template_id: id, language: 'en', subject: EN_SUBJECT, body_html: EN_HTML, body_text: 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();
|
||||
|
||||
beforeEach(async () => {
|
||||
await dropTables();
|
||||
await createTables();
|
||||
});
|
||||
|
||||
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(EN_SUBJECT);
|
||||
expect(de.body_html).not.toBe(EN_HTML);
|
||||
expect(de.body_text).not.toBe(EN_TEXT);
|
||||
expect(de.subject).toContain('Fotogalerie');
|
||||
expect(de.body_html).toContain('Galerie erfolgreich erstellt');
|
||||
expect(de.body_text).toContain('Passwort');
|
||||
// The English row is not collateral damage.
|
||||
const en = await rowFor(id, 'en');
|
||||
expect(en.body_html).toBe(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(['event_date', 'event_name', 'expiry_date', 'gallery_link', 'gallery_password', 'host_name']);
|
||||
expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('repairs the legacy _de columns too', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const master = await knex('email_templates').where({ id }).first();
|
||||
expect(master.body_html_de).not.toBe(EN_HTML);
|
||||
expect(master.body_html_de).toContain('Galerie erfolgreich erstellt');
|
||||
expect(master.subject_de).not.toBe(EN_SUBJECT);
|
||||
expect(master.body_html_en).toBe(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 erfolgreich erstellt</h2><p>Liebe(r) {{host_name}},</p>';
|
||||
const id = await seedFreshInstall({
|
||||
deSubject: 'Ihre Fotogalerie ist bereit!',
|
||||
deHtml: legacyDe,
|
||||
deText: 'Galerie erfolgreich erstellt',
|
||||
});
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect((await rowFor(id, 'de')).body_html).toBe(legacyDe);
|
||||
expect((await knex('email_templates').where({ id }).first()).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 erfolgreich erstellt');
|
||||
});
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const id = await seedFreshInstall();
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await rowFor(id, 'de');
|
||||
await migration.up(knex);
|
||||
const twice = await rowFor(id, 'de');
|
||||
|
||||
expect(twice.body_html).toBe(once.body_html);
|
||||
expect(await knex('email_template_translations').where({ template_id: id, language: 'de' }).count())
|
||||
.toEqual([{ 'count(*)': 1 }]);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Migration 194: give `gallery_created` a real German translation.
|
||||
*
|
||||
* On a fresh install the German copy of this template is the ENGLISH copy,
|
||||
* verbatim. Migration 059 introduces the multilingual columns by seeding
|
||||
* `subject_de`/`body_html_de`/`body_text_de` from their `_en` counterparts
|
||||
* ("Copy to German as default"), and migration 075 then materialises exactly
|
||||
* those columns as the `de` row in `email_template_translations`. The proper
|
||||
* German lived only in the LEGACY migrations (009/026), which never run on a
|
||||
* fresh install — so every install created since then mails English to
|
||||
* German-locale recipients, while nl/pt/ru/fr/es/sl are all localised.
|
||||
*
|
||||
* This is the most customer-visible transactional mail we send (one per
|
||||
* published gallery), so it is repaired as a content UPDATE: a code-only fix
|
||||
* would leave every existing install on the English-as-German row forever,
|
||||
* because Knex will not re-run 059/075.
|
||||
*
|
||||
* Idempotent, and deliberately conservative about WHICH rows it touches: the
|
||||
* German row is rewritten only while it is still byte-identical to the English
|
||||
* one (or empty), which is precisely the broken state. A legacy install whose
|
||||
* German came from migration 026, or any install where an admin has edited the
|
||||
* template themselves, is left alone.
|
||||
*
|
||||
* The placeholder set matches the English original exactly — host_name,
|
||||
* event_name, event_date, gallery_link, gallery_password, expiry_date — which
|
||||
* is also the template's declared `variables` array.
|
||||
*/
|
||||
|
||||
const SUBJECT_DE = 'Ihre Fotogalerie ist bereit!';
|
||||
|
||||
const 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 TEXT_DE = 'Galerie erfolgreich erstellt\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!\n\nVeranstaltungsdatum: {{event_date}}\nLink zur Galerie: {{gallery_link}}\nPasswort: {{gallery_password}}\nVerfügbar bis: {{expiry_date}}\n\nTeilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.';
|
||||
|
||||
// "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();
|
||||
};
|
||||
|
||||
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; // Template not seeded on this install — nothing to fix.
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
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: SUBJECT_DE,
|
||||
body_html: HTML_DE,
|
||||
body_text: TEXT_DE,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
} else if (isUntranslated(deRow.body_html, englishHtml)) {
|
||||
await knex('email_template_translations')
|
||||
.where({ id: deRow.id })
|
||||
.update({
|
||||
subject: SUBJECT_DE,
|
||||
body_html: HTML_DE,
|
||||
body_text: TEXT_DE,
|
||||
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: SUBJECT_DE,
|
||||
body_html_de: HTML_DE,
|
||||
body_text_de: TEXT_DE,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// No-op: reverting would restore English-as-German. Admins who want
|
||||
// different copy can edit it under Settings → Email → Templates.
|
||||
};
|
||||
Reference in New Issue
Block a user