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');
});
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([
@@ -27,11 +27,12 @@
*
* 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.
* - REPAIR: rewrite a German field (and the legacy `_de` columns) only
* while it is still byte-identical to the English one, or empty. That is
* precisely the broken state. Each of subject / body_html / body_text is
* judged on its own, so a legacy install whose German came from migration
* 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
* when the master row is missing entirely. Never overwrites an existing
* row.
@@ -163,7 +164,9 @@ async function repairGerman(knex, templateKey, german, now) {
.where({ template_id: master.id, language: 'de' })
.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 englishText = (enRow && enRow.body_text) || master.body_text_en || master.body_text || '';
if (!deRow) {
await knex('email_template_translations').insert({
@@ -175,30 +178,41 @@ async function repairGerman(knex, templateKey, german, now) {
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,
});
} else {
// Each field is judged on its own, as in migration 194. Gating all three
// on body_html would overwrite a subject the admin had already translated
// whenever the HTML still matched English — and down() is a deliberate
// no-op, so that loss would be unrecoverable.
const patch = {};
if (isUntranslated(deRow.subject, englishSubject)) patch.subject = german.subject;
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
// 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,
});
if (cols.body_html_de) {
// Same per-field rule as the translations table above.
const legacyPatch = {};
if (cols.subject_de && isUntranslated(master.subject_de, master.subject_en)) {
legacyPatch.subject_de = german.subject;
}
if (isUntranslated(master.body_html_de, master.body_html_en)) {
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;
}