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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user