diff --git a/backend/__tests__/migrations/194_german_gallery_created_translation.test.js b/backend/__tests__/migrations/194_german_gallery_created_translation.test.js new file mode 100644 index 00000000..972c9b24 --- /dev/null +++ b/backend/__tests__/migrations/194_german_gallery_created_translation.test.js @@ -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 = `
Dear {{host_name}},
+Your photo gallery "{{event_name}}" has been created successfully!
+Gallery Details:
+Share this link and password with your guests to allow them to view and download photos.
`; +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 = 'Liebe(r) {{host_name}},
'; + 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(); + }); +}); diff --git a/backend/migrations/core/194_german_gallery_created_translation.js b/backend/migrations/core/194_german_gallery_created_translation.js new file mode 100644 index 00000000..c9ff7ae3 --- /dev/null +++ b/backend/migrations/core/194_german_gallery_created_translation.js @@ -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 = `Guten Tag {{host_name}},
+Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!
+Details zur Galerie:
+Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.
`; + +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. +};