fix(migrations): judge each German field on its own in migration 195

repairGerman gated subject, body_html and body_text on body_html alone, the
same defect Codex found in migration 194: an admin who had translated only
the subject lost it the moment the HTML still matched English, and down()
is a deliberate no-op, so the loss was unrecoverable. Each field is now
judged independently for both the translations row and the legacy _de
columns, matching 194's corrected pattern. Two tests pin the two
directions (translated subject over English body, and the reverse).
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:11 +02:00
parent a929affd7e
commit 4515632300
2 changed files with 66 additions and 23 deletions
@@ -203,6 +203,35 @@ describe('migration 195 — German + seeding for the remaining gallery-lifecycle
expect((await rowFor(id, 'de')).body_html).toContain('Galerie läuft bald ab'); expect((await rowFor(id, 'de')).body_html).toContain('Galerie läuft bald ab');
}); });
it('keeps a subject the admin translated while repairing the still-English body', async () => {
// Each field is judged on its own. Gating on body_html alone would have
// thrown this subject away — and down() is a no-op, so for good.
const adminSubject = 'Nur noch kurz: Ihre Galerie läuft ab';
const id = await seedFreshInstall({ deSubject: adminSubject });
await migration.up(knex);
const de = await rowFor(id, 'de');
expect(de.subject).toBe(adminSubject);
expect(de.body_html).toContain('Galerie läuft bald ab');
expect(de.body_text).toContain('Tagen ab');
const master = await masterFor('expiration_warning');
expect(master.subject_de).toBe(adminSubject);
expect(master.body_html_de).toContain('Galerie läuft bald ab');
});
it('keeps an admin-translated body while repairing a still-English subject', async () => {
const adminHtml = '<p>Hallo {{host_name}}, in {{days_remaining}} Tagen ist Schluss: {{gallery_link}} ({{event_name}})</p>';
const id = await seedFreshInstall({ deHtml: adminHtml });
await migration.up(knex);
const de = await rowFor(id, 'de');
expect(de.body_html).toBe(adminHtml);
expect(de.subject).toBe('Ihre Fotogalerie läuft bald ab');
expect((await masterFor('expiration_warning')).body_html_de).toBe(adminHtml);
});
}); });
describe.each([ describe.each([
@@ -27,11 +27,12 @@
* *
* This migration therefore does two things, both conservative: * This migration therefore does two things, both conservative:
* *
* - REPAIR: rewrite a German row (and the legacy `_de` columns) only while * - REPAIR: rewrite a German field (and the legacy `_de` columns) only
* it is still byte-identical to the English one, or empty. That is * 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 * precisely the broken state. Each of subject / body_html / body_text is
* migration 026, or any install where an admin edited the template, is * judged on its own, so a legacy install whose German came from migration
* left untouched. * 026, or any install where an admin edited even one field, keeps what it
* has.
* - SEED: insert `gallery_expired` / `archive_complete` with EN + DE only * - SEED: insert `gallery_expired` / `archive_complete` with EN + DE only
* when the master row is missing entirely. Never overwrites an existing * when the master row is missing entirely. Never overwrites an existing
* row. * row.
@@ -163,7 +164,9 @@ async function repairGerman(knex, templateKey, german, now) {
.where({ template_id: master.id, language: 'de' }) .where({ template_id: master.id, language: 'de' })
.first(); .first();
const englishSubject = (enRow && enRow.subject) || master.subject_en || master.subject || '';
const englishHtml = (enRow && enRow.body_html) || master.body_html_en || master.body_html || ''; const englishHtml = (enRow && enRow.body_html) || master.body_html_en || master.body_html || '';
const englishText = (enRow && enRow.body_text) || master.body_text_en || master.body_text || '';
if (!deRow) { if (!deRow) {
await knex('email_template_translations').insert({ await knex('email_template_translations').insert({
@@ -175,30 +178,41 @@ async function repairGerman(knex, templateKey, german, now) {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
} else if (isUntranslated(deRow.body_html, englishHtml)) { } else {
await knex('email_template_translations') // Each field is judged on its own, as in migration 194. Gating all three
.where({ id: deRow.id }) // on body_html would overwrite a subject the admin had already translated
.update({ // whenever the HTML still matched English — and down() is a deliberate
subject: german.subject, // no-op, so that loss would be unrecoverable.
body_html: german.body_html, const patch = {};
body_text: german.body_text, if (isUntranslated(deRow.subject, englishSubject)) patch.subject = german.subject;
updated_at: now, if (isUntranslated(deRow.body_html, englishHtml)) patch.body_html = german.body_html;
}); if (isUntranslated(deRow.body_text, englishText)) patch.body_text = german.body_text;
if (Object.keys(patch).length > 0) {
patch.updated_at = now;
await knex('email_template_translations').where({ id: deRow.id }).update(patch);
}
} }
} }
// Legacy per-language columns on the master row — still the fallback path in // Legacy per-language columns on the master row — still the fallback path in
// emailProcessor.processTemplate when the translations table is unavailable. // emailProcessor.processTemplate when the translations table is unavailable.
const cols = await knex('email_templates').columnInfo(); const cols = await knex('email_templates').columnInfo();
if (cols.body_html_de && isUntranslated(master.body_html_de, master.body_html_en)) { if (cols.body_html_de) {
await knex('email_templates') // Same per-field rule as the translations table above.
.where({ id: master.id }) const legacyPatch = {};
.update({ if (cols.subject_de && isUntranslated(master.subject_de, master.subject_en)) {
subject_de: german.subject, legacyPatch.subject_de = german.subject;
body_html_de: german.body_html, }
body_text_de: german.body_text, if (isUntranslated(master.body_html_de, master.body_html_en)) {
updated_at: now, legacyPatch.body_html_de = german.body_html;
}); }
if (cols.body_text_de && isUntranslated(master.body_text_de, master.body_text_en)) {
legacyPatch.body_text_de = german.body_text;
}
if (Object.keys(legacyPatch).length > 0) {
legacyPatch.updated_at = now;
await knex('email_templates').where({ id: master.id }).update(legacyPatch);
}
} }
return true; return true;
} }