Merge pull request #803 from PicPeak/fix/event-type-hardcoded-deps

fix(event-types): un-hardcode event type dependencies in v1 API and CRM
This commit is contained in:
Paul Nothaft
2026-07-15 23:02:35 +02:00
committed by GitHub
6 changed files with 156 additions and 23 deletions
@@ -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);
});
});
@@ -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();
});
});
+52 -2
View File
@@ -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
// ──────────────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -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,
+24 -1
View File
@@ -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<string>} - 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
};
+1 -19
View File
@@ -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