diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js index 2a1fa7b6..713dcda1 100644 --- a/backend/__tests__/routes/adminEvents.smoke.test.js +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -117,6 +117,46 @@ describe('admin events CRUD endpoints (smoke)', () => { expect(queued).toHaveLength(0); }); + it('409s (not 500) when the slug uniqueness race is lost', async () => { + // The route mints the slug with a read-then-insert, so two concurrent + // creates for the same name + date both clear the existence check and + // the loser's INSERT trips events_slug_unique. Reproduce it without a + // timer: slip the colliding row in the instant that existence SELECT is + // issued — the route then spends a bcrypt hash before its own INSERT. + const { slugify } = require('../../src/utils/slug'); + const collidingSlug = `wedding-${slugify('Race Wedding')}-2026-09-02`; + let injected = null; + const onQuery = (q) => { + if (injected) return; + if (!/from\s+.?events.?\s+where\s+.?slug.?\s*=/i.test(q.sql)) return; + injected = insertEvent(db, adminId, { slug: collidingSlug, event_name: 'Race Wedding' }); + }; + db.on('query', onQuery); + + try { + const res = await auth(request(app).post('/api/admin/events')).send({ + event_type: 'wedding', + event_name: 'Race Wedding', + event_date: '2026-09-02', + customer_name: 'Client Person', + customer_email: 'client@example.com', + admin_email: 'admin@example.com', + require_password: false, + is_draft: true, + }); + + expect(injected).not.toBeNull(); // the race was actually injected + await injected; + expect(res.status).toBe(409); + expect(res.body.code).toBe('EVENT_SLUG_TAKEN'); + expect(res.body.error).toMatch(/already exists/i); + // Only the injected row survives — no half-created duplicate. + expect(await db('events').where({ slug: collidingSlug })).toHaveLength(1); + } finally { + db.removeListener('query', onQuery); + } + }); + it('400s on an invalid event type', async () => { const res = await auth(request(app).post('/api/admin/events')).send({ event_type: 'not-a-real-type', diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index bf1c818f..5fc011f6 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -18,6 +18,7 @@ const { validatePasswordInContext, getBcryptRounds } = require('../../utils/pass const logger = require('../../utils/logger'); const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog'); const { errorResponse } = require('../../utils/routeHelpers'); +const { isUniqueViolation } = require('../../utils/dbErrors'); const { buildShareLinkVariants } = require('../../services/shareLinkService'); const { parseBooleanInput } = require('../../utils/parsers'); const eventTypeService = require('../../services/eventTypeService'); @@ -31,6 +32,31 @@ const downloadZipService = require('../../services/downloadZipService'); const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults'); const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); +/** + * `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert + * (`while (await db('events').where({ slug }).first())`) — a check two + * concurrent requests for the same name + date can both pass. The loser's + * INSERT then raises the unique violation, which surfaced as a raw 500 with + * "duplicate key value violates unique constraint events_slug_unique". + * + * Detect that one constraint specifically so the client gets an actionable + * 409 instead. Deliberately narrower than isUniqueViolation alone: the events + * table carries other unique columns (share_token), and a loose message test + * would misfire because knex prefixes the whole INSERT — which always mentions + * `slug` — to err.message. + */ +function isDuplicateSlugError(error) { + if (!isUniqueViolation(error)) return false; + // Postgres names the constraint; sqlite names the offending column. + if (error.constraint) return /slug/i.test(error.constraint); + return /unique constraint failed:.*\bevents\.slug\b/i.test(String(error.message || '')); +} + +const DUPLICATE_SLUG_RESPONSE = { + error: 'An event with this name already exists. Change the event name or date and try again.', + code: 'EVENT_SLUG_TAKEN', +}; + /** * Validate a gallery password the admin re-typed, against the SAME policy * event creation applies. @@ -810,6 +836,10 @@ module.exports = (router) => { created_at: new Date().toISOString() }); } catch (error) { + if (isDuplicateSlugError(error)) { + logger.warn('Event creation lost the slug race', { error: error.message }); + return res.status(409).json(DUPLICATE_SLUG_RESPONSE); + } errorResponse(res, error, 500, 'Failed to create event'); } }); @@ -1470,6 +1500,10 @@ module.exports = (router) => { is_draft: true, }); } catch (error) { + if (isDuplicateSlugError(error)) { + logger.warn('Event duplication lost the slug race', { error: error.message }); + return res.status(409).json(DUPLICATE_SLUG_RESPONSE); + } errorResponse(res, error, 500, 'Failed to duplicate event'); } });