diff --git a/backend/__tests__/integration/eventTypeDefaults.test.js b/backend/__tests__/integration/eventTypeDefaults.test.js new file mode 100644 index 00000000..1c0a3c53 --- /dev/null +++ b/backend/__tests__/integration/eventTypeDefaults.test.js @@ -0,0 +1,50 @@ +/** + * Catalog-driven event-type defaults (#800 follow-up). + * + * The contract→event conversion used to hardcode `event_type: 'wedding'` and + * the v1 API validated against a fixed whitelist. Both now follow the live + * event_types catalog; these tests pin the shared resolver. + */ + +const { bootCrmDb } = require('./helpers/crmDb'); + +describe('resolveDefaultEventType follows the catalog', () => { + let db; + let cleanup; + let eventTypeService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + // Require AFTER bootCrmDb so the service shares this db instance + // (see crmDb.js — a second knex pool on one SQLite file deadlocks). + eventTypeService = require('../../src/services/eventTypeService'); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + it("prefers the 'other' catch-all while it is active", async () => { + expect(await eventTypeService.resolveDefaultEventType()).toBe('other'); + }); + + it('falls over to the first active type when other is deactivated', async () => { + const other = await db('event_types').where({ slug_prefix: 'other' }).first(); + await db('event_types').where({ id: other.id }).update({ is_active: 0 }); + + const resolved = await eventTypeService.resolveDefaultEventType(); + expect(resolved).not.toBe('other'); + expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy(); + + await db('event_types').where({ id: other.id }).update({ is_active: 1 }); + }); + + it("returns the literal 'other' only for an empty catalog", async () => { + const rows = await db('event_types').select('*'); + await db('event_types').del(); + + expect(await eventTypeService.resolveDefaultEventType()).toBe('other'); + + await db('event_types').insert(rows); + }); +}); diff --git a/backend/src/routes/v1/__tests__/events.create.test.js b/backend/src/routes/v1/__tests__/events.create.test.js index a47b1daf..c40d6724 100644 --- a/backend/src/routes/v1/__tests__/events.create.test.js +++ b/backend/src/routes/v1/__tests__/events.create.test.js @@ -80,7 +80,16 @@ jest.mock('../../../services/webhookService', () => ({ buildEventSubject: jest.fn().mockReturnValue({}), })); +// event_type is validated against the live event_types catalog (#800) — +// that lookup would consume the first queued db() chain and shift the +// call sequence these tests pin. Stub it valid; the invalid path has its +// own test below. +jest.mock('../../../services/eventTypeService', () => ({ + isValidEventType: jest.fn().mockResolvedValue(true), +})); + const { db } = require('../../../database/db'); +const { isValidEventType } = require('../../../services/eventTypeService'); const eventsRouter = require('../events'); const buildApp = () => { @@ -231,4 +240,16 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { .send({ ...BASE_BODY, feedback_enabled: 'maybe' }) .expect(400); }); + + it('rejects an event_type unknown to the catalog with 400 (#800)', async () => { + isValidEventType.mockResolvedValueOnce(false); + const res = await request(buildApp()) + .post('/events') + .send({ ...BASE_BODY, event_type: 'nope' }) + .expect(400); + + expect(isValidEventType).toHaveBeenCalledWith('nope'); + expect(JSON.stringify(res.body.errors)).toContain('event_type'); + expect(db).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 22a19b25..0cba9a97 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -26,6 +26,7 @@ const logger = require('../../utils/logger'); const { slugify } = require('../../utils/slug'); const { formatBoolean } = require('../../utils/dbCompat'); const { parseBooleanInput } = require('../../utils/parsers'); +const { isValidEventType } = require('../../services/eventTypeService'); const router = express.Router(); @@ -80,7 +81,7 @@ const photoUpload = multer({ * event_name: { type: string } * event_type: * type: string - * enum: [wedding, birthday, corporate, other, family] + * description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values." * event_date: { type: string, format: date, nullable: true } * customer_name: { type: string, nullable: true } * customer_email: { type: string, format: email, nullable: true } @@ -117,7 +118,14 @@ router.post( requireApiScope('admin'), [ body('event_name').isString().trim().notEmpty(), - body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']), + // Validate against the live event_types catalog (admins can rename/delete + // the defaults and add custom types), not a hardcoded whitelist (#800). + body('event_type').isString().trim().notEmpty().bail().custom(async (value) => { + if (!(await isValidEventType(value))) { + throw new Error('Unknown event type — must match an active event type slug'); + } + return true; + }), body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(), body('customer_name').optional({ nullable: true }).isString(), body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(), @@ -447,6 +455,48 @@ router.get( } ); +// ────────────────────────────────────────────────────────────────────────── +// GET /event-types — read (catalog discovery for event creation, #800) +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /event-types: + * get: + * tags: [Events] + * summary: List active event types + * description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them. + * security: [{ bearerAuth: [] }] + * responses: + * 200: + * description: Active event types + * content: + * application/json: + * schema: + * type: object + * properties: + * eventTypes: + * type: array + * items: + * type: object + * properties: + * slug_prefix: { type: string } + * name: { type: string } + * emoji: { type: string } + */ +router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const types = await db('event_types') + .where('is_active', formatBoolean(true)) + .orderBy('display_order', 'asc') + .select('slug_prefix', 'name', 'emoji'); + res.json({ eventTypes: types }); + } catch (error) { + logger.error('v1 GET /event-types failed', { error: error.message }); + res.status(500).json({ error: 'Failed to list event types' }); + } +}); + // ────────────────────────────────────────────────────────────────────────── // GET /events/:id — read // ────────────────────────────────────────────────────────────────────────── diff --git a/backend/src/services/contract/conversions.js b/backend/src/services/contract/conversions.js index 23230383..2b3548cb 100644 --- a/backend/src/services/contract/conversions.js +++ b/backend/src/services/contract/conversions.js @@ -11,6 +11,7 @@ const businessProfileService = require('../businessProfileService'); const { ensureSystemBlocksSeeded } = require('../contractBlocksService'); const { ensureInt } = require('../../utils/numericHelpers'); const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers'); +const { resolveDefaultEventType } = require('../eventTypeService'); /** @@ -221,6 +222,12 @@ async function convertToEvent(contractId, adminId) { const placeholderHash = crypto.randomBytes(32).toString('hex'); const shareToken = crypto.randomBytes(32).toString('hex'); + // Event type: the configurable org default, else the resolved catch-all — + // same chain as quoteService.convertToEvent. Never a hardcoded slug: the + // admin may have renamed or deleted 'wedding' (#800). + const eventType = (await getAppSetting('crm_default_event_type')) + || (await resolveDefaultEventType()); + const eventCols = await db('events').columnInfo(); const candidate = { slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, @@ -236,7 +243,7 @@ async function convertToEvent(contractId, adminId) { customer_email: customerEmail, customer_phone: customer.phone, admin_email: adminEmail, - event_type: 'wedding', + event_type: eventType, password_hash: placeholderHash, share_link: shareToken, share_token: shareToken, diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index a7d26798..4990563f 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -428,6 +428,28 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => { return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' }; }; +/** + * Resolve the fallback event type for document→event conversions (quotes, + * contracts) when the source carries none. Never hardcodes a specific slug + * (any of them, incl. 'other', can be disabled by the admin): prefer the + * generic 'other' catch-all when it's active, else the first active type by + * display order, and only fall back to the literal 'other' if the catalog is + * somehow empty/unreadable. + * @param {Object} [conn] - Optional knex connection/transaction + * @returns {Promise} - slug_prefix to use + */ +const resolveDefaultEventType = async (conn) => { + const q = conn || db; + try { + const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix'); + if (other) return 'other'; + const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix'); + return firstActive?.slug_prefix || 'other'; + } catch (_) { + return 'other'; + } +}; + module.exports = { getAllEventTypes, getActiveEventTypes, @@ -439,5 +461,6 @@ module.exports = { updateEventType, deleteEventType, reorderEventTypes, - getEventTypeForSlug + getEventTypeForSlug, + resolveDefaultEventType }; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index d2fd5dc5..91f7d906 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -34,6 +34,7 @@ const { cleanNetMinor } = require('../utils/invoiceRounding'); const { AppError } = require('../utils/errors'); const { formatBoolean } = require('../utils/dbCompat'); const { nextDocumentNumber } = require('../utils/documentSequences'); +const { resolveDefaultEventType } = require('./eventTypeService'); const { formatShortDate } = require('../utils/dateFormatter'); const businessProfileService = require('./businessProfileService'); const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); @@ -308,25 +309,6 @@ async function nextQuoteNumber(trx) { return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx); } -/** - * Resolve the fallback event type for a quote→event conversion when the quote - * itself carries none. Never hardcodes a specific slug (any of them, incl. - * 'other', can be disabled by the admin): prefer the generic 'other' catch-all - * when it's active, else the first active type by display order, and only fall - * back to the literal 'other' if the catalog is somehow empty/unreadable. - */ -async function resolveDefaultEventType(conn) { - const q = conn || db; - try { - const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix'); - if (other) return 'other'; - const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix'); - return firstActive?.slug_prefix || 'other'; - } catch (_) { - return 'other'; - } -} - function ensureCustomerFeatureEnabled(customer, feature) { // Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`) // is checked at the route layer (feature flag); here we only enforce