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:
Luca
2026-06-24 13:52:42 +02:00
parent 10d091b55e
commit 415c93a512
2 changed files with 107 additions and 1 deletions
@@ -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');
});
});
+43 -1
View File
@@ -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);
};