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:
Paul Nothaft
2026-09-01 16:40:02 +02:00
parent 76a1453fa7
commit 73b08a7b5c
2 changed files with 317 additions and 0 deletions
@@ -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.
};