feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup wizard — rename, delete or replace the seeded defaults while nothing references them. On existing installs system types stay protected. - New wizard step between features and config: edit name/URL prefix, remove, or add types; defaults shown as recommendations - setup_wizard_completed app setting (migration 161): seeded true when an admin already exists, false on fresh installs; POST /api/setup/ complete (adminAuth) flips it when the wizard finishes - deleteEventType: system types deletable only while the flag is unset; in-use check extended to quotes; per-type reminder template (event_reminder_<slug>) is deleted with the type - reminder-template self-heal no longer resurrects templates for slugs removed from the catalog - v1 API event creation validates event_type against the live catalog instead of a hardcoded whitelist (custom types were rejected; the never-seeded 'family' slug is no longer silently accepted) - contract→event conversion resolves the event type via crm_default_event_type / resolveDefaultEventType instead of hardcoding 'wedding' (resolveDefaultEventType moved from quoteService to eventTypeService for reuse)
This commit is contained in:
@@ -10,6 +10,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -79,4 +80,19 @@ router.post('/admin', [
|
||||
}
|
||||
});
|
||||
|
||||
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
|
||||
// admin exists (the wizard is authenticated from the account step onward), so
|
||||
// it takes the normal admin auth. One-way: while the flag is unset the seeded
|
||||
// SYSTEM event types may be deleted from the wizard's event-types step; once
|
||||
// set they are permanently protected (#800).
|
||||
router.post('/complete', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
res.json({ completed: true });
|
||||
} catch (err) {
|
||||
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to mark setup complete' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -266,7 +266,22 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
|
||||
}
|
||||
};
|
||||
|
||||
// Per-type templates are only seeded for slugs that still exist in the
|
||||
// event_types catalog — the setup wizard (and admins) can delete the
|
||||
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
|
||||
// type would resurrect an orphan on every boot (#800). The catch-all
|
||||
// event_reminder_default is always seeded.
|
||||
let existingSlugs = null;
|
||||
if (await db.schema.hasTable('event_types')) {
|
||||
const rows = await db('event_types').select('slug_prefix');
|
||||
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
|
||||
}
|
||||
|
||||
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
|
||||
const typeSlug = templateKey.replace(/^event_reminder_/, '');
|
||||
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let existing = await db('email_templates').where({ template_key: templateKey }).first();
|
||||
|
||||
|
||||
@@ -250,6 +250,12 @@ const updateEventType = async (id, updates) => {
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
*
|
||||
* System types are protected — EXCEPT during the first-run setup wizard
|
||||
* (setup_wizard_completed flag unset, see setupService), where the admin may
|
||||
* replace the seeded defaults before anything references them (#800). The
|
||||
* in-use checks below still apply in that window as defense in depth.
|
||||
*
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
@@ -261,11 +267,17 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types
|
||||
// Prevent deletion of system types once the setup wizard has completed.
|
||||
if (eventType.is_system) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
// Lazy require: keeps the module graph flat (setupService has no
|
||||
// dependency back on this service, but the require is only needed on
|
||||
// this rare path).
|
||||
const { isSetupWizardCompleted } = require('./setupService');
|
||||
if (await isSetupWizardCompleted()) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
@@ -280,7 +292,40 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db('event_types').where('id', id).del();
|
||||
// Quotes carry event_type too (migration 146) — a dangling slug there would
|
||||
// corrupt the quote→event conversion default chain.
|
||||
if (await hasColumnCached('quotes', 'event_type')) {
|
||||
const quotesUsingType = await db('quotes')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve schema lookups BEFORE opening the transaction — a global-db read
|
||||
// inside a SQLite transaction (single connection) deadlocks. Same pattern
|
||||
// as the rename cascade in updateEventType above.
|
||||
const hasTranslations = await db.schema.hasTable('email_template_translations');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_types').where('id', id).del();
|
||||
// Drop the per-type reminder template with the type, or it lingers as an
|
||||
// orphan (invisible in the Reminder Emails tab, which derives its rows
|
||||
// from the live catalog).
|
||||
const tpl = await trx('email_templates')
|
||||
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
|
||||
.first('id');
|
||||
if (tpl) {
|
||||
if (hasTranslations) {
|
||||
await trx('email_template_translations').where({ template_id: tpl.id }).del();
|
||||
}
|
||||
await trx('email_templates').where({ id: tpl.id }).del();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
@@ -341,6 +386,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 or deleted 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,
|
||||
@@ -352,5 +419,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
|
||||
|
||||
@@ -20,6 +20,21 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
// is permanently closed once setup is done — safe even on a public IP.
|
||||
const SETUP_TOKEN_KEY = 'setup_token';
|
||||
|
||||
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
|
||||
// completed on installs that predate the wizard's event-types step). While it
|
||||
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
|
||||
// types may be deleted (eventTypeService.deleteEventType), because nothing
|
||||
// can reference them yet. Once true, system types are permanently protected.
|
||||
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
|
||||
|
||||
async function isSetupWizardCompleted() {
|
||||
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) === true;
|
||||
}
|
||||
|
||||
async function markSetupWizardCompleted() {
|
||||
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
|
||||
}
|
||||
|
||||
async function noAdminExists() {
|
||||
const row = await db('admin_users').count({ c: '*' }).first();
|
||||
return Number(row?.c || 0) === 0;
|
||||
@@ -173,4 +188,11 @@ async function createInitialAdmin({ token, email, password, ip }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
|
||||
module.exports = {
|
||||
getSetupStatus,
|
||||
ensureSetupToken,
|
||||
verifySetupToken,
|
||||
createInitialAdmin,
|
||||
isSetupWizardCompleted,
|
||||
markSetupWizardCompleted,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user