From 7eb6357b4a9bf3914674a63afa386a5fcf8c2161 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Wed, 15 Jul 2026 21:30:23 +0200
Subject: [PATCH] feat(setup): event-types step in first-run wizard +
un-hardcode event type deps (#800)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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_) 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)
---
.../integration/eventTypeSetupWindow.test.js | 121 +++++++++++
.../core/161_setup_wizard_completed_flag.js | 43 ++++
backend/src/routes/setup.js | 16 ++
backend/src/routes/v1/events.js | 10 +-
backend/src/services/contract/conversions.js | 9 +-
.../src/services/eventReminderTemplates.js | 15 ++
backend/src/services/eventTypeService.js | 80 +++++++-
backend/src/services/quoteService.js | 20 +-
backend/src/services/setupService.js | 24 ++-
.../components/admin/SetupEventTypesStep.tsx | 193 ++++++++++++++++++
frontend/src/i18n/locales/de.json | 13 ++
frontend/src/i18n/locales/en.json | 13 ++
frontend/src/pages/SetupPage.tsx | 56 +++--
frontend/src/services/setup.service.ts | 7 +
14 files changed, 573 insertions(+), 47 deletions(-)
create mode 100644 backend/__tests__/integration/eventTypeSetupWindow.test.js
create mode 100644 backend/migrations/core/161_setup_wizard_completed_flag.js
create mode 100644 frontend/src/components/admin/SetupEventTypesStep.tsx
diff --git a/backend/__tests__/integration/eventTypeSetupWindow.test.js b/backend/__tests__/integration/eventTypeSetupWindow.test.js
new file mode 100644
index 00000000..3f43d3f6
--- /dev/null
+++ b/backend/__tests__/integration/eventTypeSetupWindow.test.js
@@ -0,0 +1,121 @@
+/**
+ * Setup-window event type deletion (#800).
+ *
+ * The first-run setup wizard may delete the seeded SYSTEM event types —
+ * but ONLY while the `setup_wizard_completed` flag is unset (migration 161
+ * seeds it false on a fresh install, true when an admin already exists).
+ * These tests pin the whole contract:
+ *
+ * - fresh install → flag false → system types deletable (in-use checks
+ * still apply), and the per-type reminder template goes with the type
+ * - reminder-template self-heal does NOT resurrect templates for slugs
+ * that no longer exist in the catalog
+ * - after markSetupWizardCompleted() → system deletion is refused again
+ * - resolveDefaultEventType never returns a hardcoded slug that the
+ * admin removed (contract→event conversion default, #800)
+ */
+
+const { bootCrmDb } = require('./helpers/crmDb');
+
+describe('event type deletion during the setup window (#800)', () => {
+ let db;
+ let cleanup;
+ let eventTypeService;
+ let setupService;
+ let ensureEventReminderTemplatesSeeded;
+
+ beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+ // Require AFTER bootCrmDb so every service shares this db instance
+ // (see crmDb.js — a second knex pool on one SQLite file deadlocks).
+ eventTypeService = require('../../src/services/eventTypeService');
+ setupService = require('../../src/services/setupService');
+ ({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
+ }, 120000);
+
+ afterAll(async () => {
+ if (cleanup) await cleanup();
+ });
+
+ it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
+ const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
+ expect(row).toBeTruthy();
+ expect(JSON.parse(row.setting_value)).toBe(false);
+ expect(await setupService.isSetupWizardCompleted()).toBe(false);
+ });
+
+ it('refuses to delete a system type that events already use, even in the window', async () => {
+ const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
+ await db('events').insert({
+ slug: 'corporate-test-2026-01-01',
+ event_name: 'Test',
+ event_type: 'corporate',
+ event_date: '2026-01-01',
+ host_email: 'host@example.com',
+ admin_email: 'admin@example.com',
+ password_hash: 'x',
+ share_link: 'share-corporate-test',
+ expires_at: new Date(Date.now() + 86400000),
+ });
+
+ await expect(eventTypeService.deleteEventType(corporate.id))
+ .rejects.toMatchObject({ code: 'IN_USE' });
+ });
+
+ it('deletes an unused system type in the window, taking its reminder template along', async () => {
+ // Seed the per-type reminder templates first so there is something to clean up.
+ await ensureEventReminderTemplatesSeeded(db);
+ expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
+
+ const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
+ expect(wedding.is_system).toBeTruthy();
+
+ const result = await eventTypeService.deleteEventType(wedding.id);
+ expect(result.success).toBe(true);
+
+ expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
+ expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
+ });
+
+ it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
+ // The seeder caches success per process — reset the module to force a
+ // genuine second pass, exactly what a backend restart would run.
+ jest.resetModules();
+ const fresh = require('../../src/services/eventReminderTemplates');
+ await fresh.ensureEventReminderTemplatesSeeded(db);
+
+ expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
+ // Types still in the catalog keep their templates.
+ expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
+ expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
+ });
+
+ it('re-locks system types once the wizard is marked complete', async () => {
+ await setupService.markSetupWizardCompleted();
+ expect(await setupService.isSetupWizardCompleted()).toBe(true);
+
+ const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
+ await expect(eventTypeService.deleteEventType(birthday.id))
+ .rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
+
+ // Custom (non-system) types remain deletable as before.
+ const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
+ const result = await eventTypeService.deleteEventType(custom.id);
+ expect(result.success).toBe(true);
+ });
+
+ it('resolveDefaultEventType follows the catalog instead of hardcoding a slug', async () => {
+ // 'other' is active → preferred catch-all.
+ expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
+
+ // Deactivate 'other' → falls over to the first active type by display order.
+ 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');
+ const resolvedRow = await db('event_types').where({ slug_prefix: resolved }).first();
+ expect(resolvedRow).toBeTruthy();
+
+ await db('event_types').where({ id: other.id }).update({ is_active: 1 });
+ });
+});
diff --git a/backend/migrations/core/161_setup_wizard_completed_flag.js b/backend/migrations/core/161_setup_wizard_completed_flag.js
new file mode 100644
index 00000000..fd50585d
--- /dev/null
+++ b/backend/migrations/core/161_setup_wizard_completed_flag.js
@@ -0,0 +1,43 @@
+/**
+ * Migration 161: `setup_wizard_completed` app setting (#800).
+ *
+ * The setup wizard gains an event-types step that may rename or DELETE the
+ * seeded system event types. That is only safe on a pristine install, so the
+ * backend gates system-type deletion on this flag being unset (plus zero
+ * usage — see eventTypeService.deleteEventType).
+ *
+ * Backfill rule: any install that already has an admin account predates the
+ * wizard step (or already finished the wizard), so it is marked completed
+ * here — the deletion window never opens on existing setups. A genuinely
+ * fresh install runs this migration BEFORE its first admin is created, so
+ * the flag starts false and the wizard's finish call flips it to true.
+ *
+ * Idempotent: skips when the key already exists. Values are JSON-stringified
+ * to match getAppSetting's JSON.parse on read.
+ */
+exports.up = async function up(knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+
+ const existing = await knex('app_settings')
+ .where({ setting_key: 'setup_wizard_completed' })
+ .first();
+ if (existing) return;
+
+ let hasAdmin = false;
+ if (await knex.schema.hasTable('admin_users')) {
+ const row = await knex('admin_users').count({ c: '*' }).first();
+ hasAdmin = Number(row?.c || 0) > 0;
+ }
+
+ await knex('app_settings').insert({
+ setting_key: 'setup_wizard_completed',
+ setting_value: JSON.stringify(hasAdmin),
+ setting_type: 'boolean',
+ updated_at: new Date(),
+ });
+};
+
+exports.down = async function down(knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+ await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
+};
diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js
index 1acafbbf..36f9ec24 100644
--- a/backend/src/routes/setup.js
+++ b/backend/src/routes/setup.js
@@ -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;
diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js
index 22a19b25..7896ab78 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();
@@ -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(),
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/eventReminderTemplates.js b/backend/src/services/eventReminderTemplates.js
index f640d276..12f9db52 100644
--- a/backend/src/services/eventReminderTemplates.js
+++ b/backend/src/services/eventReminderTemplates.js
@@ -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_ 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();
diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js
index fb6d701f..9cbbb6f5 100644
--- a/backend/src/services/eventTypeService.js
+++ b/backend/src/services/eventTypeService.js
@@ -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
{(step === 'token' || step === 'account' || step === 'usage') && (
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
{t('setup.back')}
+ ) : step === 'eventTypes' ? (
+
) : step === 'config' ? (
{
variant="primary"
size="lg"
className="w-full"
- onClick={() => navigate('/admin/dashboard', { replace: true })}
+ onClick={async () => {
+ // One-way marker: re-locks the seeded system event types
+ // (#800). Best-effort — a failure must not trap the user on
+ // the thank-you screen, and the flag re-arms nothing risky
+ // (the delete window also requires zero usage server-side).
+ try { await setupService.completeSetup(); } catch { /* best-effort */ }
+ navigate('/admin/dashboard', { replace: true });
+ }}
rightIcon={}
>
{t('setup.community.finish')}
diff --git a/frontend/src/services/setup.service.ts b/frontend/src/services/setup.service.ts
index b069b9b3..3556d373 100644
--- a/frontend/src/services/setup.service.ts
+++ b/frontend/src/services/setup.service.ts
@@ -38,4 +38,11 @@ export const setupService = {
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
return response.data;
},
+
+ // One-way wizard-finish marker (authenticated — runs after the admin
+ // exists). While unset, the wizard's event-types step may delete the
+ // seeded system types; afterwards they are permanently protected.
+ async completeSetup(): Promise {
+ await api.post('/setup/complete');
+ },
};