From 41e1de78186eda12f8f53f4d3454a6d3b7b110b0 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 18:50:49 +0200 Subject: [PATCH] fix(email): repair and seed the gallery lifecycle templates Correction: the reported premise held for only one of the three templates, verified by running the core migration set against an empty database. - expiration_warning is German-is-English on every fresh install, exactly as reported. Repaired with migration 194's pattern verbatim. - gallery_expired and archive_complete are NOT German-is-English -- they do not exist at all. Their master rows are inserted only by migrations/legacy/ 010+020, which never run on a fresh install, so 075/099/106/108 seeded zero translations for them (they key off a master row that is not there). A fresh install's email_templates holds 17 keys and neither is among them. The consequence is worse than a translation gap: expirationChecker's sendGalleryExpiredEmails and archiveService's completion mail both hit "Email template not found", retry three times and die silently in email_queue on every expiry and every archive. So 195 also seeds those two (master row + en/de translations + category), but only when the master row is absent -- it never overwrites. English follows legacy 028, which emailProcessor's own comments call the shipped copy; German follows legacy 026's wording. Both are restructured into the plain unstyled shape the other core-seeded templates use, so wrapEmailHtml's configurable palette governs styling rather than hard-coded hex. The support-contact line is wrapped in {{#if support_email}} because getSupportEmail() can return ''. 196 adds the {{#if welcome_message}} block that nl/pt/ru/fr/es/sl already have in gallery_created but en and de lack, so the photographer's personal note was silently dropped for those two locales even though the value is passed at send time. safeTemplateReplace does resolve {{#if}} before variable substitution, so this is a real conditional -- there is a test rendering the migrated body both ways. HTML body only, matching the other locales: emailProcessor rewrites welcome_message through formatWelcomeMessage (escape + nl2br) once for both bodies, so the text part would print literal
and &. Both migrations keep 194's conservative condition -- rewrite only while the German is still byte-identical to English or empty -- so admin-edited and legacy-translated installs are untouched. Idempotent, guarded, no-op down(). Known gap, documented in 195's header: the two newly seeded templates get en/de only. nl/pt/ru/fr/es/sl fall back to en via processTemplate's fallback chain, which is strictly better than today's hard failure but is not real localisation. Refs testplan REPORT.md B1, B2. --- ...german_gallery_lifecycle_templates.test.js | 329 ++++++++++++++++++ ...lery_created_welcome_message_block.test.js | 267 ++++++++++++++ .../195_german_gallery_lifecycle_templates.js | 282 +++++++++++++++ ...6_gallery_created_welcome_message_block.js | 118 +++++++ 4 files changed, 996 insertions(+) create mode 100644 backend/__tests__/migrations/195_german_gallery_lifecycle_templates.test.js create mode 100644 backend/__tests__/migrations/196_gallery_created_welcome_message_block.test.js create mode 100644 backend/migrations/core/195_german_gallery_lifecycle_templates.js create mode 100644 backend/migrations/core/196_gallery_created_welcome_message_block.js diff --git a/backend/__tests__/migrations/195_german_gallery_lifecycle_templates.test.js b/backend/__tests__/migrations/195_german_gallery_lifecycle_templates.test.js new file mode 100644 index 00000000..63236ee6 --- /dev/null +++ b/backend/__tests__/migrations/195_german_gallery_lifecycle_templates.test.js @@ -0,0 +1,329 @@ +/** + * Migration 195 covers the three gallery-lifecycle mails migration 194 left + * behind, and they turned out to be broken in two different ways: + * + * - `expiration_warning` ships German-that-is-English on every fresh install + * (059 copies `_en` into `_de`, 075 materialises that as the `de` row, and + * the real German only ever lived in legacy migration 026). + * - `gallery_expired` / `archive_complete` are not seeded by any core + * migration at all, so a fresh install has no row for them and both mail + * paths fail with "Email template '' not found". + * + * What is pinned here is as much about restraint as repair: the repair path may + * only overwrite a German row that is still the English one, and the seed path + * may only insert when the row is absent. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const migration = require('../../migrations/core/195_german_gallery_lifecycle_templates'); + +// The English copy of expiration_warning seeded by migration 001, verbatim. +const WARN_EN_SUBJECT = 'Your Photo Gallery Expires Soon'; +const WARN_EN_HTML = `

Gallery Expiring Soon

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

+

After expiration, the gallery will be archived and no longer accessible to guests.

+

Visit Gallery

`; +const WARN_EN_TEXT = 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.'; +const WARN_VARIABLES = ['host_name', 'event_name', 'days_remaining', 'gallery_link']; + +// Captures both plain {{var}} tokens and the {{#if var}} / {{/if}} pair, so an +// unbalanced or renamed conditional shows up as a placeholder-set mismatch. +const placeholdersOf = (...parts) => { + const found = new Set(); + for (const part of parts) { + for (const match of String(part || '').matchAll(/\{\{\s*(#if\s+[\w.]+|\/if|[\w.]+)\s*\}\}/g)) { + found.add(match[1].replace(/\s+/g, ' ')); + } + } + return [...found].sort(); +}; + +describe('migration 195 — German + seeding for the remaining gallery-lifecycle mails', () => { + let knex; let tmpDir; + + beforeAll(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig195-')); + 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('category'); + t.string('subcategory'); + t.string('feature_flag'); + 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: expiration_warning exists with the + * English copy in BOTH the `_en` and `_de` columns (and both translation + * rows); gallery_expired / archive_complete do not exist at all. + */ + const seedFreshInstall = async ({ deHtml = WARN_EN_HTML, deSubject = WARN_EN_SUBJECT, deText = WARN_EN_TEXT } = {}) => { + const [id] = await knex('email_templates').insert({ + template_key: 'expiration_warning', + subject_en: WARN_EN_SUBJECT, + subject_de: deSubject, + body_html_en: WARN_EN_HTML, + body_html_de: deHtml, + body_text_en: WARN_EN_TEXT, + body_text_de: deText, + variables: JSON.stringify(WARN_VARIABLES), + category: 'core', + subcategory: 'gallery', + }); + await knex('email_template_translations').insert([ + { template_id: id, language: 'en', subject: WARN_EN_SUBJECT, body_html: WARN_EN_HTML, body_text: WARN_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(); + + const masterFor = async (templateKey) => + knex('email_templates').where({ template_key: templateKey }).first(); + + const translationsFor = async (templateKey) => { + const master = await masterFor(templateKey); + return knex('email_template_translations').where({ template_id: master.id }); + }; + + beforeEach(async () => { + await dropTables(); + await createTables(); + }); + + describe('expiration_warning — repair', () => { + 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(WARN_EN_SUBJECT); + expect(de.body_html).not.toBe(WARN_EN_HTML); + expect(de.body_text).not.toBe(WARN_EN_TEXT); + expect(de.subject).toContain('Fotogalerie'); + expect(de.body_html).toContain('Galerie läuft bald ab'); + expect(de.body_text).toContain('Tagen ab'); + // The English row is not collateral damage. + expect((await rowFor(id, 'en')).body_html).toBe(WARN_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(['days_remaining', 'event_name', 'gallery_link', 'host_name']); + expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected); + // ...which is also the template's declared `variables` array. + expect(expected).toEqual([...WARN_VARIABLES].sort()); + }); + + it('repairs the legacy _de columns too', async () => { + await seedFreshInstall(); + + await migration.up(knex); + + const master = await masterFor('expiration_warning'); + expect(master.body_html_de).not.toBe(WARN_EN_HTML); + expect(master.body_html_de).toContain('Galerie läuft bald ab'); + expect(master.subject_de).not.toBe(WARN_EN_SUBJECT); + expect(master.body_html_en).toBe(WARN_EN_HTML); + }); + + it('leaves an already-translated German row (and columns) alone', async () => { + // What a legacy install carries after legacy migration 026. + const legacyDe = '

Galerie läuft bald ab

Liebe(r) {{host_name}},

'; + const id = await seedFreshInstall({ + deSubject: 'Ihre Fotogalerie läuft bald ab', + deHtml: legacyDe, + deText: 'Galerie läuft bald ab', + }); + + await migration.up(knex); + + expect((await rowFor(id, 'de')).body_html).toBe(legacyDe); + expect((await masterFor('expiration_warning')).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 läuft bald ab'); + }); + }); + + describe.each([ + ['gallery_expired', 'Galerie abgelaufen', 'Gallery Expired'], + ['archive_complete', 'Archivierung abgeschlossen', 'Archive Complete'], + ])('%s — seed', (templateKey, germanMarker, englishMarker) => { + it('inserts the master row a fresh install never got', async () => { + await seedFreshInstall(); + + await migration.up(knex); + + const master = await masterFor(templateKey); + expect(master).toBeDefined(); + expect(master.category).toBe('core'); + expect(master.subcategory).toBe('gallery'); + expect(JSON.parse(master.variables)).toContain('event_name'); + expect(master.body_html_en).toContain(englishMarker); + expect(master.body_html_de).toContain(germanMarker); + }); + + it('inserts en + de translations whose German differs from the English', async () => { + await seedFreshInstall(); + + await migration.up(knex); + + const rows = await translationsFor(templateKey); + expect(rows.map((r) => r.language).sort()).toEqual(['de', 'en']); + const en = rows.find((r) => r.language === 'en'); + const de = rows.find((r) => r.language === 'de'); + expect(de.subject).not.toBe(en.subject); + expect(de.body_html).not.toBe(en.body_html); + expect(de.body_text).not.toBe(en.body_text); + expect(de.body_html).toContain(germanMarker); + }); + + it('uses exactly the placeholder set of the English original', async () => { + await seedFreshInstall(); + + await migration.up(knex); + + const rows = await translationsFor(templateKey); + const en = rows.find((r) => r.language === 'en'); + const de = rows.find((r) => r.language === 'de'); + const expected = placeholdersOf(en.subject, en.body_html, en.body_text); + expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected); + // The support-contact line is conditional on both sides — getSupportEmail() + // returns '' when nothing is configured, and an unbalanced {{#if}}/{{/if}} + // would leave literal markup in the rendered mail. + expect(expected).toContain('#if support_email'); + expect(expected).toContain('/if'); + }); + + it('declares exactly the variables the send path fills', async () => { + await seedFreshInstall(); + + await migration.up(knex); + + const master = await masterFor(templateKey); + const rows = await translationsFor(templateKey); + const en = rows.find((r) => r.language === 'en'); + const used = placeholdersOf(en.subject, en.body_html, en.body_text) + .filter((p) => !p.startsWith('#') && !p.startsWith('/')); + expect(JSON.parse(master.variables).sort()).toEqual(used); + }); + + it('never overwrites an existing row', async () => { + // What a legacy install carries: the row exists and legacy 026 already + // gave it real German. + await seedFreshInstall(); + const legacyDe = `

${germanMarker}

Liebe(r) {{host_name}},

`; + const [id] = await knex('email_templates').insert({ + template_key: templateKey, + subject_en: 'admin edited', + subject_de: 'vom Admin bearbeitet', + body_html_en: '

admin edited

', + body_html_de: legacyDe, + body_text_en: 'admin edited', + body_text_de: 'vom Admin bearbeitet', + variables: JSON.stringify(['event_name']), + }); + await knex('email_template_translations').insert([ + { template_id: id, language: 'en', subject: 'admin edited', body_html: '

admin edited

', body_text: 'admin edited' }, + { template_id: id, language: 'de', subject: 'vom Admin bearbeitet', body_html: legacyDe, body_text: 'vom Admin bearbeitet' }, + ]); + + await migration.up(knex); + + const master = await masterFor(templateKey); + expect(master.body_html_en).toBe('

admin edited

'); + expect(master.body_html_de).toBe(legacyDe); + const rows = await translationsFor(templateKey); + expect(rows.find((r) => r.language === 'en').body_html).toBe('

admin edited

'); + expect(rows.find((r) => r.language === 'de').body_html).toBe(legacyDe); + }); + }); + + it('is idempotent', async () => { + const id = await seedFreshInstall(); + + await migration.up(knex); + const onceDe = await rowFor(id, 'de'); + const onceRows = await knex('email_template_translations').select('*').orderBy('id'); + await migration.up(knex); + const twiceDe = await rowFor(id, 'de'); + const twiceRows = await knex('email_template_translations').select('*').orderBy('id'); + + expect(twiceDe.body_html).toBe(onceDe.body_html); + expect(twiceRows.length).toBe(onceRows.length); + expect(await knex('email_templates').count('* as c')) + .toEqual([{ c: 3 }]); + }); + + it('skips the repair when the template is absent, and no-ops without the tables', async () => { + // No expiration_warning row at all — the repair must not throw, and the two + // seeded templates still land. + await expect(migration.up(knex)).resolves.toBeUndefined(); + expect(await masterFor('expiration_warning')).toBeUndefined(); + expect(await masterFor('gallery_expired')).toBeDefined(); + + await dropTables(); + await expect(migration.up(knex)).resolves.toBeUndefined(); + await createTables(); + }); +}); diff --git a/backend/__tests__/migrations/196_gallery_created_welcome_message_block.test.js b/backend/__tests__/migrations/196_gallery_created_welcome_message_block.test.js new file mode 100644 index 00000000..63a42077 --- /dev/null +++ b/backend/__tests__/migrations/196_gallery_created_welcome_message_block.test.js @@ -0,0 +1,267 @@ +/** + * `welcome_message` is on the queued payload of every gallery_created mail + * (adminEvents/crud.js:139) and nl/pt/ru/fr/es/sl all render it — but the + * English copy seeded by migration 001 has no `{{#if welcome_message}}` block, + * and migration 194's German was written against that English. So EN and DE + * recipients silently lost the photographer's personal note to their client. + * + * The guard is byte-identity with the copy this codebase seeded: an + * admin-edited body, and a legacy install whose EN/DE came from legacy 028/026 + * (both of which already carry a welcome_message block), must survive + * untouched. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const migration = require('../../migrations/core/196_gallery_created_welcome_message_block'); +const { safeTemplateReplace } = require('../../src/services/emailProcessor'); + +// The English copy seeded by migration 001, verbatim. +const EN_SUBJECT = 'Your Photo Gallery is Ready!'; +const EN_HTML = `

Gallery Created Successfully

+

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!'; + +// The German copy written by migration 194, verbatim. +const DE_SUBJECT = 'Ihre Fotogalerie ist bereit!'; +const DE_HTML = `

Galerie erfolgreich erstellt

+

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 DE_TEXT = 'Galerie erfolgreich erstellt\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!'; + +// The exact one-liner nl/pt/ru (075), fr (099), es (106) and sl (108) use. +const SIBLING_BLOCK = '{{#if welcome_message}}

{{welcome_message}}

{{/if}}'; + +const SEEDED_VARIABLES = ['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']; + +const placeholdersOf = (...parts) => { + const found = new Set(); + for (const part of parts) { + for (const match of String(part || '').matchAll(/\{\{\s*(#if\s+[\w.]+|\/if|[\w.]+)\s*\}\}/g)) { + found.add(match[1].replace(/\s+/g, ' ')); + } + } + return [...found].sort(); +}; + +describe('migration 196 — {{#if welcome_message}} for the EN/DE gallery_created copy', () => { + let knex; let tmpDir; + + beforeAll(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig196-')); + 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 once migration 194 has run. */ + const seedPost194 = async ({ enHtml = EN_HTML, deHtml = DE_HTML, variables = SEEDED_VARIABLES } = {}) => { + const [id] = await knex('email_templates').insert({ + template_key: 'gallery_created', + subject_en: EN_SUBJECT, + subject_de: DE_SUBJECT, + body_html_en: enHtml, + body_html_de: deHtml, + body_text_en: EN_TEXT, + body_text_de: DE_TEXT, + variables: JSON.stringify(variables), + }); + await knex('email_template_translations').insert([ + { template_id: id, language: 'en', subject: EN_SUBJECT, body_html: enHtml, body_text: EN_TEXT }, + { template_id: id, language: 'de', subject: DE_SUBJECT, body_html: deHtml, body_text: DE_TEXT }, + // A sibling locale that already had the block all along. + { template_id: id, language: 'nl', subject: 'Uw fotogalerij is klaar!', body_html: `

Beste {{host_name}},

\n${SIBLING_BLOCK}`, body_text: 'Beste {{host_name}},' }, + ]); + return id; + }; + + const rowFor = async (templateId, language) => + knex('email_template_translations').where({ template_id: templateId, language }).first(); + + beforeEach(async () => { + await dropTables(); + await createTables(); + }); + + it('adds the block to the English and German bodies', async () => { + const id = await seedPost194(); + + await migration.up(knex); + + expect((await rowFor(id, 'en')).body_html).toBe(`${EN_HTML}\n${SIBLING_BLOCK}`); + expect((await rowFor(id, 'de')).body_html).toBe(`${DE_HTML}\n${SIBLING_BLOCK}`); + }); + + it('uses the same block shape as the sibling locales', async () => { + const id = await seedPost194(); + + await migration.up(knex); + + const nl = await rowFor(id, 'nl'); + for (const language of ['en', 'de']) { + const row = await rowFor(id, language); + expect(row.body_html.endsWith(SIBLING_BLOCK)).toBe(true); + expect(row.body_html).toContain(nl.body_html.split('\n').pop()); + } + }); + + it('keeps the German placeholder set identical to the English', async () => { + const id = await seedPost194(); + + 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).toContain('welcome_message'); + expect(expected).toContain('#if welcome_message'); + expect(expected).toContain('/if'); + expect(placeholdersOf(de.subject, de.body_html, de.body_text)).toEqual(expected); + }); + + it('repairs the legacy _de/_en columns too, and declares the variable', async () => { + const id = await seedPost194(); + + await migration.up(knex); + + const master = await knex('email_templates').where({ id }).first(); + expect(master.body_html_en).toBe(`${EN_HTML}\n${SIBLING_BLOCK}`); + expect(master.body_html_de).toBe(`${DE_HTML}\n${SIBLING_BLOCK}`); + expect(JSON.parse(master.variables)).toEqual([...SEEDED_VARIABLES, 'welcome_message']); + }); + + it('leaves the plain-text bodies alone', async () => { + // formatWelcomeMessage HTML-escapes and nl2br's the value once, for both + // bodies — dropping it into the text part would print literal
. + const id = await seedPost194(); + + await migration.up(knex); + + expect((await rowFor(id, 'en')).body_text).toBe(EN_TEXT); + expect((await rowFor(id, 'de')).body_text).toBe(DE_TEXT); + }); + + it('leaves an admin-edited body untouched', async () => { + const edited = '

Our own wording

Hi {{host_name}}

'; + const id = await seedPost194({ enHtml: edited, deHtml: edited }); + + await migration.up(knex); + + expect((await rowFor(id, 'en')).body_html).toBe(edited); + expect((await rowFor(id, 'de')).body_html).toBe(edited); + const master = await knex('email_templates').where({ id }).first(); + expect(master.body_html_en).toBe(edited); + expect(master.body_html_de).toBe(edited); + }); + + it('leaves a legacy install (whose copy already has the block) untouched', async () => { + const legacyEn = '

Hello {{host_name}},

\n{{#if welcome_message}}\n

{{welcome_message}}

\n{{/if}}'; + const legacyDe = '

Hallo {{host_name}},

\n{{#if welcome_message}}\n

{{welcome_message}}

\n{{/if}}'; + const id = await seedPost194({ enHtml: legacyEn, deHtml: legacyDe }); + + await migration.up(knex); + + expect((await rowFor(id, 'en')).body_html).toBe(legacyEn); + expect((await rowFor(id, 'de')).body_html).toBe(legacyDe); + }); + + it('is idempotent', async () => { + const id = await seedPost194(); + + await migration.up(knex); + const once = await rowFor(id, 'en'); + const onceMaster = await knex('email_templates').where({ id }).first(); + await migration.up(knex); + const twice = await rowFor(id, 'en'); + const twiceMaster = await knex('email_templates').where({ id }).first(); + + expect(twice.body_html).toBe(once.body_html); + expect(twiceMaster.variables).toBe(onceMaster.variables); + expect(await knex('email_template_translations').where({ template_id: id }).count('* as c')) + .toEqual([{ c: 3 }]); + }); + + 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(); + }); + + describe('the block actually renders', () => { + it('shows the message when one is set and drops the block when it is not', async () => { + const id = await seedPost194(); + await migration.up(knex); + const html = (await rowFor(id, 'en')).body_html; + + const withMessage = safeTemplateReplace(html, { welcome_message: 'See you there!' }, { escapeHtml: true }); + expect(withMessage).toContain('See you there!'); + expect(withMessage).not.toContain('{{#if'); + expect(withMessage).not.toContain('{{/if}}'); + + const without = safeTemplateReplace(html, { welcome_message: '' }, { escapeHtml: true }); + expect(without).not.toContain('welcome_message'); + expect(without).not.toContain(''); + }); + }); +}); diff --git a/backend/migrations/core/195_german_gallery_lifecycle_templates.js b/backend/migrations/core/195_german_gallery_lifecycle_templates.js new file mode 100644 index 00000000..058c6ebf --- /dev/null +++ b/backend/migrations/core/195_german_gallery_lifecycle_templates.js @@ -0,0 +1,282 @@ +/** + * Migration 195: finish the job migration 194 started for the remaining three + * customer-facing gallery-lifecycle mails — `expiration_warning`, + * `gallery_expired` and `archive_complete`. + * + * Investigating them turned up TWO different fresh-install defects, not one: + * + * 1. `expiration_warning` is the exact defect 194 fixed for + * `gallery_created`: migration 001 seeds it, 059 copies `_en` into `_de` + * ("Copy to German as default"), 075 materialises those columns as the + * `de` translation row, and the real German only ever existed in the + * LEGACY migrations (026), which never run on a fresh install. Every + * install created since then mails English to German-locale recipients + * while nl/pt/ru/fr/es/sl are all localised. + * + * 2. `gallery_expired` and `archive_complete` are NOT German-is-English on a + * fresh install — they are ABSENT. Their master rows are only ever + * inserted by legacy migrations 010/020; no core migration seeds them. + * Verified by running the core migration set against an empty database: + * the resulting `email_templates` holds 17 keys and neither of these two + * is among them. So `expirationChecker.sendGalleryExpiredEmails` and + * `archiveService`'s completion mail both hit + * `Email template '' not found` in emailProcessor (line 759) on + * every fresh install — the queue row then retries three times and dies + * silently. 075/099/106/108 seeded no translations for them either, + * because they key off a master row that does not exist. + * + * 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. + * - SEED: insert `gallery_expired` / `archive_complete` with EN + DE only + * when the master row is missing entirely. Never overwrites an existing + * row. + * + * The German copy follows legacy migration 026's wording — the translation + * that was always intended — restructured to mirror the English it sits next + * to, so the placeholder set of each German body matches its English original + * exactly. The seeded English follows legacy 028 (which emailProcessor's own + * comments treat as the shipped copy) but in the plain, unstyled shape the + * other core-seeded templates use, so `wrapEmailHtml`'s configurable email + * palette governs the styling instead of hard-coded hex values. + * + * Known gap, deliberately not closed here: the two seeded templates get en/de + * only. nl/pt/ru/fr/es/sl fall back to `en` via processTemplate's fallback + * chain, which is strictly better than today's hard failure. + */ + +// ── expiration_warning ──────────────────────────────────────────────── +// Placeholders mirror the English original seeded by migration 001: +// host_name, event_name, days_remaining, gallery_link. + +const WARNING_SUBJECT_DE = 'Ihre Fotogalerie läuft bald ab'; + +const WARNING_HTML_DE = `

Galerie läuft bald ab

+

Guten Tag {{host_name}},

+

Ihre Fotogalerie „{{event_name}}“ läuft in {{days_remaining}} Tagen ab.

+

Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich.

+

Galerie ansehen

`; + +const WARNING_TEXT_DE = 'Galerie läuft bald ab\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ läuft in {{days_remaining}} Tagen ab.'; + +// ── gallery_expired ─────────────────────────────────────────────────── +// Variables filled by expirationChecker.sendGalleryExpiredEmails. +// {{support_email}} is wrapped in a conditional because getSupportEmail() +// returns '' when neither branding_support_email nor an SMTP from-address +// is configured (see emailProcessor.js:110). + +const EXPIRED_VARIABLES = ['host_name', 'event_name', 'event_date', 'expiry_date', 'support_email']; + +const EXPIRED_EN = { + subject: 'Your Photo Gallery Has Expired', + body_html: `

Gallery Expired

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.

+

Your photos have been archived safely — nothing is lost.

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Expired: {{expiry_date}}
  • +
+{{#if support_email}}

If you need access to the archived photos, contact us at {{support_email}}.

{{/if}}`, + body_text: 'Gallery Expired\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.\n\nYour photos have been archived safely — nothing is lost.\n\nEvent Date: {{event_date}}\nExpired: {{expiry_date}}\n\n{{#if support_email}}If you need access to the archived photos, contact us at {{support_email}}.{{/if}}', +}; + +const EXPIRED_DE = { + subject: 'Ihre Fotogalerie ist abgelaufen', + body_html: `

Galerie abgelaufen

+

Guten Tag {{host_name}},

+

Ihre Fotogalerie „{{event_name}}“ ist am {{expiry_date}} abgelaufen und online nicht mehr zugänglich.

+

Ihre Fotos wurden sicher archiviert — es geht nichts verloren.

+

Details zur Galerie:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Abgelaufen am: {{expiry_date}}
  • +
+{{#if support_email}}

Wenn Sie Zugriff auf die archivierten Fotos benötigen, wenden Sie sich an {{support_email}}.

{{/if}}`, + body_text: 'Galerie abgelaufen\n\nGuten Tag {{host_name}},\n\nIhre Fotogalerie „{{event_name}}“ ist am {{expiry_date}} abgelaufen und online nicht mehr zugänglich.\n\nIhre Fotos wurden sicher archiviert — es geht nichts verloren.\n\nVeranstaltungsdatum: {{event_date}}\nAbgelaufen am: {{expiry_date}}\n\n{{#if support_email}}Wenn Sie Zugriff auf die archivierten Fotos benötigen, wenden Sie sich an {{support_email}}.{{/if}}', +}; + +// ── archive_complete ────────────────────────────────────────────────── +// Variables filled by archiveService. This one goes to the admin address. + +const ARCHIVE_VARIABLES = ['host_name', 'event_name', 'event_date', 'archive_date', 'photo_count', 'archive_size', 'support_email']; + +const ARCHIVE_EN = { + subject: 'Archive Complete: {{event_name}}', + body_html: `

Archive Complete

+

Dear {{host_name}},

+

The photo gallery "{{event_name}}" has been archived successfully.

+

Archive Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Archived: {{archive_date}}
  • +
  • Number of Photos: {{photo_count}}
  • +
  • Archive Size: {{archive_size}}
  • +
+

The archive is stored securely and can be restored if needed.

+{{#if support_email}}

Questions? Contact us at {{support_email}}.

{{/if}}`, + body_text: 'Archive Complete\n\nDear {{host_name}},\n\nThe photo gallery "{{event_name}}" has been archived successfully.\n\nEvent Date: {{event_date}}\nArchived: {{archive_date}}\nNumber of Photos: {{photo_count}}\nArchive Size: {{archive_size}}\n\nThe archive is stored securely and can be restored if needed.\n\n{{#if support_email}}Questions? Contact us at {{support_email}}.{{/if}}', +}; + +const ARCHIVE_DE = { + subject: 'Archivierung abgeschlossen: {{event_name}}', + body_html: `

Archivierung abgeschlossen

+

Guten Tag {{host_name}},

+

Die Fotogalerie „{{event_name}}“ wurde erfolgreich archiviert.

+

Details zum Archiv:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Archiviert am: {{archive_date}}
  • +
  • Anzahl der Fotos: {{photo_count}}
  • +
  • Archivgröße: {{archive_size}}
  • +
+

Das Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.

+{{#if support_email}}

Fragen? Wenden Sie sich an {{support_email}}.

{{/if}}`, + body_text: 'Archivierung abgeschlossen\n\nGuten Tag {{host_name}},\n\nDie Fotogalerie „{{event_name}}“ wurde erfolgreich archiviert.\n\nVeranstaltungsdatum: {{event_date}}\nArchiviert am: {{archive_date}}\nAnzahl der Fotos: {{photo_count}}\nArchivgröße: {{archive_size}}\n\nDas Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.\n\n{{#if support_email}}Fragen? Wenden Sie sich an {{support_email}}.{{/if}}', +}; + +// "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(); +}; + +/** + * Rewrite the German translation row + legacy `_de` columns of an existing + * template, but only while they are still the English copy (or empty). + */ +async function repairGerman(knex, templateKey, german, now) { + const master = await knex('email_templates').where('template_key', templateKey).first(); + if (!master) return false; + + 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: german.subject, + body_html: german.body_html, + body_text: german.body_text, + 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, + }); + } + } + + // 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, + }); + } + return true; +} + +/** + * Insert a master row + en/de translations for a template that is missing + * entirely. No-op when the row already exists — this never overwrites. + */ +async function seedTemplate(knex, templateKey, { en, de, variables }, now) { + const existing = await knex('email_templates').where('template_key', templateKey).first(); + if (existing) return false; + + const cols = await knex('email_templates').columnInfo(); + const masterRow = { template_key: templateKey, variables: JSON.stringify(variables) }; + if ('category' in cols) masterRow.category = 'core'; + if ('subcategory' in cols) masterRow.subcategory = 'gallery'; + if ('feature_flag' in cols) masterRow.feature_flag = null; + if ('created_at' in cols) masterRow.created_at = now; + if ('updated_at' in cols) masterRow.updated_at = now; + + // Fill whichever of the legacy subject_*/body_html_*/body_text_* columns the + // schema still carries: German into `_de`, English everywhere else. + for (const colName of Object.keys(cols)) { + const source = /_de$/.test(colName) ? de : en; + if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = source.subject; + } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = source.body_html; + } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = source.body_text; + } + } + + const inserted = await knex('email_templates').insert(masterRow).returning('id'); + const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (templateId && await knex.schema.hasTable('email_template_translations')) { + for (const [language, content] of [['en', en], ['de', de]]) { + await knex('email_template_translations').insert({ + template_id: templateId, + language, + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + created_at: now, + updated_at: now, + }); + } + } + return true; +} + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + const now = new Date().toISOString(); + + await repairGerman(knex, 'expiration_warning', { + subject: WARNING_SUBJECT_DE, + body_html: WARNING_HTML_DE, + body_text: WARNING_TEXT_DE, + }, now); + + for (const [key, content] of [ + ['gallery_expired', { en: EXPIRED_EN, de: EXPIRED_DE, variables: EXPIRED_VARIABLES }], + ['archive_complete', { en: ARCHIVE_EN, de: ARCHIVE_DE, variables: ARCHIVE_VARIABLES }], + ]) { + const seeded = await seedTemplate(knex, key, content, now); + if (!seeded) { + // Row already there (a legacy install). Repair its German the same + // conservative way — a no-op wherever legacy 026 already translated it. + await repairGerman(knex, key, content.de, now); + } + } +}; + +exports.down = async function() { + // No-op: reverting would restore English-as-German and delete templates the + // expiry/archive mail paths depend on. Admins who want different copy can + // edit it under Settings → Email → Templates. +}; diff --git a/backend/migrations/core/196_gallery_created_welcome_message_block.js b/backend/migrations/core/196_gallery_created_welcome_message_block.js new file mode 100644 index 00000000..44efc0f4 --- /dev/null +++ b/backend/migrations/core/196_gallery_created_welcome_message_block.js @@ -0,0 +1,118 @@ +/** + * Migration 196: give the English (and German) `gallery_created` template the + * `{{#if welcome_message}}` block every other locale already has. + * + * `adminEvents/crud.js:139` puts `welcome_message` on the queued mail payload + * for every published gallery, and emailProcessor runs it through + * `formatWelcomeMessage` (escape + nl2br) and treats it as HTML-passthrough. + * nl/pt/ru/fr/es/sl all render it, via the identical one-liner appended to the + * end of the HTML body (migrations 075, 099, 106, 108): + * + * {{#if welcome_message}}

{{welcome_message}}

{{/if}} + * + * The English copy seeded by migration 001 has no such block, and migration + * 194's German was written against that English — so on a fresh install EN and + * DE recipients silently lose the photographer's personal note to their client. + * (Legacy installs are fine: legacy 028/026 both carry a welcome_message block.) + * + * `{{#if}}` is a real construct, not wishful markup: `safeTemplateReplace` + * (emailProcessor.js:515) resolves `{{#if var}}…{{/if}}` before variable + * substitution and drops the block when the variable is empty — so an event + * with no welcome message renders exactly as it does today. + * + * HTML body only, like the other six locales — and deliberately so: + * emailProcessor rewrites `welcome_message` through `formatWelcomeMessage` + * (HTML-escape + nl2br) once, for both bodies, so dropping it into the plain + * text part would print literal `
` and `&`. + * + * Conservative in the same way as 194/195: each body is rewritten only while + * it is still byte-identical to the copy this codebase seeded, so an + * admin-edited template is never clobbered. The declared `variables` array + * gains `welcome_message` unconditionally-if-absent — it is not admin-editable + * for seeded templates, and the six locales that already reference the + * variable need it declared for the Templates preview to substitute it. + */ + +const WELCOME_BLOCK = '\n{{#if welcome_message}}

{{welcome_message}}

{{/if}}'; + +// The English body seeded by migration 001, verbatim. +const SEEDED_HTML_EN = `

Gallery Created Successfully

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been created successfully!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Expires: {{expiry_date}}
  • +
+

Share this link and password with your guests to allow them to view and download photos.

`; + +// The German body written by migration 194, verbatim. +const SEEDED_HTML_DE = `

Galerie erfolgreich erstellt

+

Guten Tag {{host_name}},

+

Ihre Fotogalerie „{{event_name}}“ wurde erfolgreich erstellt!

+

Details zur Galerie:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Link zur Galerie: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Verfügbar bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.

`; + +const SEEDED = { en: SEEDED_HTML_EN, de: SEEDED_HTML_DE }; + +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; + + const now = new Date().toISOString(); + + if (await knex.schema.hasTable('email_template_translations')) { + for (const [language, seededHtml] of Object.entries(SEEDED)) { + const row = await knex('email_template_translations') + .where({ template_id: master.id, language }) + .first(); + if (!row || (row.body_html || '') !== seededHtml) continue; + await knex('email_template_translations') + .where({ id: row.id }) + .update({ body_html: seededHtml + WELCOME_BLOCK, updated_at: now }); + } + } + + // Legacy per-language columns — emailProcessor's fallback path. + const cols = await knex('email_templates').columnInfo(); + const columnUpdate = {}; + if (cols.body_html_en && (master.body_html_en || '') === SEEDED_HTML_EN) { + columnUpdate.body_html_en = SEEDED_HTML_EN + WELCOME_BLOCK; + } + if (cols.body_html_de && (master.body_html_de || '') === SEEDED_HTML_DE) { + columnUpdate.body_html_de = SEEDED_HTML_DE + WELCOME_BLOCK; + } + if (Object.keys(columnUpdate).length > 0) { + columnUpdate.updated_at = now; + await knex('email_templates').where({ id: master.id }).update(columnUpdate); + } + + // Declare the variable the template (and six other locales) now reference. + let variables = master.variables; + if (typeof variables === 'string') { + try { variables = JSON.parse(variables); } catch (e) { variables = null; } + } + if (Array.isArray(variables) && !variables.includes('welcome_message')) { + await knex('email_templates') + .where({ id: master.id }) + .update({ variables: JSON.stringify([...variables, 'welcome_message']), updated_at: now }); + } +}; + +exports.down = async function() { + // No-op: reverting would drop the photographer's personal note from the mail + // again. Admins who don't want it can remove the block under + // Settings → Email → Templates. +};