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:
Paul Nothaft
2026-07-15 21:30:23 +02:00
parent aab9e1a937
commit 7eb6357b4a
14 changed files with 573 additions and 47 deletions
+16
View File
@@ -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;
+9 -1
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();
@@ -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(),
+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,
@@ -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();
+74 -6
View File
@@ -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
};
+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
+23 -1
View File
@@ -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,
};