fix(event-types): renaming a type's slug cascades to events, quotes + reminder template
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_<slug>) 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_<old> template to <new> (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.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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_<slug>). 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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user