From 415c93a512f74898d0225ce2e9298f24cc12f60d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:52:42 +0200 Subject: [PATCH] fix(event-types): renaming a type's slug cascades to events, quotes + reminder template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming an event type's slug_prefix is editable in the UI but previously orphaned everything keyed on the old slug: existing events/quotes (their event_type) detached, and the authored per-type pre-event reminder template (event_reminder_) was left behind → reminders fell back to default. updateEventType now cascades atomically when the slug changes: re-points events.event_type + quotes.event_type old→new and renames the event_reminder_ template to (guarded so it never clobbers an existing target). So a photographer can rename a type to e.g. "concert" and the edited subject/body follow. Column check resolved before the transaction (avoids the SQLite global-read-in-trx deadlock). Tests cover the cascade + no-clobber. --- .../integration/eventTypeRename.test.js | 64 +++++++++++++++++++ backend/src/services/eventTypeService.js | 44 ++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/integration/eventTypeRename.test.js diff --git a/backend/__tests__/integration/eventTypeRename.test.js b/backend/__tests__/integration/eventTypeRename.test.js new file mode 100644 index 00000000..caf41597 --- /dev/null +++ b/backend/__tests__/integration/eventTypeRename.test.js @@ -0,0 +1,64 @@ +/** + * Renaming an event type's slug_prefix must CASCADE to everything keyed on the + * old slug, so a rename behaves like a rename rather than silently detaching + * existing events/quotes and orphaning the per-type pre-event reminder template. + */ +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// bootCrmDb runs the full core-migration set in beforeAll. +jest.setTimeout(30000); + +describe('event type slug rename cascade', () => { + let db; + let cleanup; + let customerId; + let eventTypeService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ customerId } = await seedMinimal(db)); + eventTypeService = require('../../src/services/eventTypeService'); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('re-points events + quotes + the reminder template from old slug to new', async () => { + // A non-system event type with slug 'party'. + const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true }); + + // An authored per-type reminder template + an event + a quote, all on 'party'. + await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' }); + await db('events').insert({ + event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(), + is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev', + event_name: 'A party', event_date: '2026-09-01', + }); + await db('quotes').insert({ + quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party', + }); + + // Rename the slug. + await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' }); + + // Event + quote follow the rename. + expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert'); + expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert'); + // The authored reminder template moved (subject/body preserved), old key gone. + expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined(); + const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first(); + expect(moved).toBeTruthy(); + expect(moved.subject_en).toBe('Party reminder'); + }); + + it('does not clobber an existing template for the new slug', async () => { + const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true }); + await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' }); + await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' }); + + await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' }); + + // Target already existed → left intact; source not force-merged over it. + expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en) + .toBe('existing soiree'); + }); +}); diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index 1dbd8308..fb6d701f 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -7,6 +7,8 @@ const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { hasColumnCached } = require('../utils/schemaCache'); +const logger = require('../utils/logger'); /** * Get all event types @@ -201,7 +203,47 @@ const updateEventType = async (id, updates) => { updateData.updated_at = new Date(); - await db('event_types').where('id', id).update(updateData); + // A slug_prefix rename must CASCADE, or it silently orphans everything keyed on + // the old slug: existing events/quotes (their event_type), and the per-type + // pre-event reminder template (event_reminder_). Re-point them so a + // rename behaves like a rename, not a detach. Atomic. + const oldSlug = eventType.slug_prefix; + const newSlug = updateData.slug_prefix; + const slugChanged = newSlug !== undefined && newSlug !== oldSlug; + + if (!slugChanged) { + await db('event_types').where('id', id).update(updateData); + return getEventTypeById(id); + } + + // Resolve schema lookups BEFORE opening the transaction — hasColumnCached + // reads via the global db, and a global read inside a SQLite transaction + // (single connection) deadlocks. + const quotesHasEventType = await hasColumnCached('quotes', 'event_type'); + + await db.transaction(async (trx) => { + await trx('event_types').where('id', id).update(updateData); + // Re-point existing documents from the old slug to the new one. + const evCount = await trx('events').where('event_type', oldSlug).update({ event_type: newSlug }); + let qCount = 0; + if (quotesHasEventType) { + qCount = await trx('quotes').where('event_type', oldSlug).update({ event_type: newSlug }); + } + // Carry the authored per-type reminder template along (subject/body follow the + // rename). Guard: never clobber an existing target template for the new slug. + const oldKey = `event_reminder_${oldSlug}`; + const newKey = `event_reminder_${newSlug}`; + let tplMoved = false; + const src = await trx('email_templates').where({ template_key: oldKey }).first('id'); + const dst = await trx('email_templates').where({ template_key: newKey }).first('id'); + if (src && !dst) { + await trx('email_templates').where({ template_key: oldKey }).update({ template_key: newKey }); + tplMoved = true; + } + logger.info('Event type slug renamed — cascaded references', { + id, oldSlug, newSlug, events: evCount, quotes: qCount, reminderTemplateMoved: tplMoved, + }); + }); return getEventTypeById(id); };