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:
@@ -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: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ const { body, validationResult } = require('express-validator');
|
|||||||
const setupService = require('../services/setupService');
|
const setupService = require('../services/setupService');
|
||||||
const { getClientIp } = require('../utils/requestIp');
|
const { getClientIp } = require('../utils/requestIp');
|
||||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const logger = require('../../utils/logger');
|
|||||||
const { slugify } = require('../../utils/slug');
|
const { slugify } = require('../../utils/slug');
|
||||||
const { formatBoolean } = require('../../utils/dbCompat');
|
const { formatBoolean } = require('../../utils/dbCompat');
|
||||||
const { parseBooleanInput } = require('../../utils/parsers');
|
const { parseBooleanInput } = require('../../utils/parsers');
|
||||||
|
const { isValidEventType } = require('../../services/eventTypeService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -117,7 +118,14 @@ router.post(
|
|||||||
requireApiScope('admin'),
|
requireApiScope('admin'),
|
||||||
[
|
[
|
||||||
body('event_name').isString().trim().notEmpty(),
|
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('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||||
body('customer_name').optional({ nullable: true }).isString(),
|
body('customer_name').optional({ nullable: true }).isString(),
|
||||||
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const businessProfileService = require('../businessProfileService');
|
|||||||
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
||||||
const { ensureInt } = require('../../utils/numericHelpers');
|
const { ensureInt } = require('../../utils/numericHelpers');
|
||||||
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
|
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 placeholderHash = crypto.randomBytes(32).toString('hex');
|
||||||
const shareToken = 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 eventCols = await db('events').columnInfo();
|
||||||
const candidate = {
|
const candidate = {
|
||||||
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
|
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
|
||||||
@@ -236,7 +243,7 @@ async function convertToEvent(contractId, adminId) {
|
|||||||
customer_email: customerEmail,
|
customer_email: customerEmail,
|
||||||
customer_phone: customer.phone,
|
customer_phone: customer.phone,
|
||||||
admin_email: adminEmail,
|
admin_email: adminEmail,
|
||||||
event_type: 'wedding',
|
event_type: eventType,
|
||||||
password_hash: placeholderHash,
|
password_hash: placeholderHash,
|
||||||
share_link: shareToken,
|
share_link: shareToken,
|
||||||
share_token: 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)) {
|
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 {
|
try {
|
||||||
let existing = await db('email_templates').where({ template_key: templateKey }).first();
|
let existing = await db('email_templates').where({ template_key: templateKey }).first();
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,12 @@ const updateEventType = async (id, updates) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an event type
|
* 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
|
* @param {number} id - Event type ID
|
||||||
* @returns {Promise<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
@@ -261,12 +267,18 @@ const deleteEventType = async (id) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent deletion of system types
|
// Prevent deletion of system types once the setup wizard has completed.
|
||||||
if (eventType.is_system) {
|
if (eventType.is_system) {
|
||||||
|
// 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.');
|
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||||
error.code = 'SYSTEM_TYPE';
|
error.code = 'SYSTEM_TYPE';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any events use this type
|
// Check if any events use this type
|
||||||
const eventsUsingType = await db('events')
|
const eventsUsingType = await db('events')
|
||||||
@@ -280,7 +292,40 @@ const deleteEventType = async (id) => {
|
|||||||
throw error;
|
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 };
|
return { success: true, deleted: eventType };
|
||||||
};
|
};
|
||||||
@@ -341,6 +386,28 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
|||||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
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 = {
|
module.exports = {
|
||||||
getAllEventTypes,
|
getAllEventTypes,
|
||||||
getActiveEventTypes,
|
getActiveEventTypes,
|
||||||
@@ -352,5 +419,6 @@ module.exports = {
|
|||||||
updateEventType,
|
updateEventType,
|
||||||
deleteEventType,
|
deleteEventType,
|
||||||
reorderEventTypes,
|
reorderEventTypes,
|
||||||
getEventTypeForSlug
|
getEventTypeForSlug,
|
||||||
|
resolveDefaultEventType
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
|
|||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { nextDocumentNumber } = require('../utils/documentSequences');
|
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||||
|
const { resolveDefaultEventType } = require('./eventTypeService');
|
||||||
const { formatShortDate } = require('../utils/dateFormatter');
|
const { formatShortDate } = require('../utils/dateFormatter');
|
||||||
const businessProfileService = require('./businessProfileService');
|
const businessProfileService = require('./businessProfileService');
|
||||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
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);
|
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) {
|
function ensureCustomerFeatureEnabled(customer, feature) {
|
||||||
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
|
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
|
||||||
// is checked at the route layer (feature flag); here we only enforce
|
// 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.
|
// is permanently closed once setup is done — safe even on a public IP.
|
||||||
const SETUP_TOKEN_KEY = 'setup_token';
|
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() {
|
async function noAdminExists() {
|
||||||
const row = await db('admin_users').count({ c: '*' }).first();
|
const row = await db('admin_users').count({ c: '*' }).first();
|
||||||
return Number(row?.c || 0) === 0;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, Input, Loading } from '../common';
|
||||||
|
import { eventTypesService, EventType } from '../../services/eventTypes.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One editable row of the wizard's event-type list. Existing rows carry the
|
||||||
|
// catalog id; rows added in the wizard have no id until Continue POSTs them.
|
||||||
|
interface RowState {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
slug_prefix: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
// First-run event-types step (#800). Shown once, during the setup wizard —
|
||||||
|
// the only window in which the seeded SYSTEM types may be deleted (nothing
|
||||||
|
// references them yet; the backend re-locks them when the wizard finishes).
|
||||||
|
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
|
||||||
|
// tunable later in Settings → Event Types.
|
||||||
|
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [rows, setRows] = useState<RowState[] | null>(null);
|
||||||
|
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
|
||||||
|
const [deletedIds, setDeletedIds] = useState<number[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const { isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['setup-event-types'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const types = await eventTypesService.getEventTypes();
|
||||||
|
// Initialize once — a re-run (remount) must not clobber in-progress edits.
|
||||||
|
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
|
||||||
|
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
|
||||||
|
return types;
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRow = (index: number, patch: Partial<RowState>) => {
|
||||||
|
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRow = (index: number) => {
|
||||||
|
// No side effects inside the setRows updater — StrictMode double-invokes
|
||||||
|
// updaters, which would enqueue the same id twice (one DELETE 404s and
|
||||||
|
// shows a false "could not save" warning).
|
||||||
|
const row = rows?.[index];
|
||||||
|
if (!row) return;
|
||||||
|
if (row.id !== undefined) {
|
||||||
|
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
|
||||||
|
}
|
||||||
|
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply the diff (delete → update → create), then advance. Best-effort like
|
||||||
|
// the other wizard steps — a partial failure warns but never traps the user;
|
||||||
|
// everything here is editable later in Settings → Event Types.
|
||||||
|
const handleContinue = async () => {
|
||||||
|
if (!rows) return;
|
||||||
|
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
|
||||||
|
if (kept.length === 0) {
|
||||||
|
toast.error(t('setup.eventTypes.atLeastOne'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
let failures = 0;
|
||||||
|
for (const id of deletedIds) {
|
||||||
|
try {
|
||||||
|
await eventTypesService.deleteEventType(id);
|
||||||
|
} catch {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of kept) {
|
||||||
|
try {
|
||||||
|
if (row.id !== undefined) {
|
||||||
|
const before = original.get(row.id);
|
||||||
|
const updates: { name?: string; slug_prefix?: string } = {};
|
||||||
|
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
|
||||||
|
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await eventTypesService.updateEventType(row.id, updates);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await eventTypesService.createEventType({
|
||||||
|
name: row.name.trim(),
|
||||||
|
slug_prefix: row.slug_prefix,
|
||||||
|
emoji: row.emoji,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || rows === null) {
|
||||||
|
return isError ? (
|
||||||
|
// Catalog unreadable — don't trap the user; the defaults stay seeded and
|
||||||
|
// remain editable later in Settings → Event Types.
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
|
||||||
|
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
|
||||||
|
{t('setup.continue')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
|
||||||
|
{t('setup.eventTypes.intro')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
|
||||||
|
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
value={row.name}
|
||||||
|
onChange={(e) => setRow(index, { name: e.target.value })}
|
||||||
|
placeholder={t('setup.eventTypes.namePlaceholder')}
|
||||||
|
aria-label={t('setup.eventTypes.nameLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 flex-shrink-0">
|
||||||
|
<Input
|
||||||
|
value={row.slug_prefix}
|
||||||
|
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
|
||||||
|
placeholder={t('setup.eventTypes.slugPlaceholder')}
|
||||||
|
aria-label={t('setup.eventTypes.slugLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRow(index)}
|
||||||
|
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
aria-label={t('common.delete', 'Delete')}
|
||||||
|
title={t('common.delete', 'Delete')}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addRow}
|
||||||
|
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 text-neutral-500" />
|
||||||
|
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isLoading={saving}
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleContinue}
|
||||||
|
>
|
||||||
|
{t('setup.continue')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
|
||||||
@@ -3611,6 +3611,19 @@
|
|||||||
"finish": "Einrichtung abschließen",
|
"finish": "Einrichtung abschließen",
|
||||||
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
|
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
|
||||||
},
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"subtitle": "Welche Veranstaltungen fotografieren Sie?",
|
||||||
|
"intro": "Veranstaltungsarten ordnen Ihre Galerien — jede Art erhält ein eigenes URL-Präfix und Standard-Theme. Die Vorschläge unten sind nur ein Startpunkt: Benennen Sie sie um, entfernen Sie Unnötiges oder fügen Sie eigene hinzu. Nur jetzt können die mitgelieferten Arten gelöscht werden; später lassen sie sich nur umbenennen oder deaktivieren.",
|
||||||
|
"nameLabel": "Anzeigename",
|
||||||
|
"namePlaceholder": "z.B. Familienshooting",
|
||||||
|
"slugLabel": "URL-Präfix",
|
||||||
|
"slugPlaceholder": "z.B. familie",
|
||||||
|
"add": "Veranstaltungsart hinzufügen",
|
||||||
|
"hint": "Das URL-Präfix erscheint in Galerie-Links (z.B. familie-mueller-2025-06-01). Symbole, Themes und Reihenfolge können Sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||||
|
"atLeastOne": "Behalten Sie mindestens eine Veranstaltungsart — jede Galerie braucht eine.",
|
||||||
|
"loadFailed": "Veranstaltungsarten konnten nicht geladen werden — Sie können sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||||
|
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen."
|
||||||
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"subtitle": "Alles bereit",
|
"subtitle": "Alles bereit",
|
||||||
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
|
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
|
||||||
|
|||||||
@@ -3500,6 +3500,19 @@
|
|||||||
"finish": "Finish setup",
|
"finish": "Finish setup",
|
||||||
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
|
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
|
||||||
},
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"subtitle": "Which events do you photograph?",
|
||||||
|
"intro": "Event types organize your galleries — each one gets its own URL prefix and default theme. The suggestions below are just a starting point: rename them, remove what you don't need, or add your own. This is the only time the built-in types can be deleted; later they can only be renamed or deactivated.",
|
||||||
|
"nameLabel": "Display name",
|
||||||
|
"namePlaceholder": "e.g. Family Shoot",
|
||||||
|
"slugLabel": "URL prefix",
|
||||||
|
"slugPlaceholder": "e.g. family",
|
||||||
|
"add": "Add event type",
|
||||||
|
"hint": "The URL prefix appears in gallery links (e.g. family-smith-2025-06-01). Icons, themes and order can be tuned later in Settings → Event Types.",
|
||||||
|
"atLeastOne": "Keep at least one event type — every gallery needs one.",
|
||||||
|
"loadFailed": "Could not load the event types — you can adjust them later in Settings → Event Types.",
|
||||||
|
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types."
|
||||||
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"subtitle": "You're all set",
|
"subtitle": "You're all set",
|
||||||
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { setupService } from '../services/setup.service';
|
|||||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||||
|
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
||||||
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
|
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
|
||||||
import type { AdminUser } from '../types';
|
import type { AdminUser } from '../types';
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export const SetupPage: React.FC = () => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config' | 'community'>('token');
|
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
|
||||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -241,17 +242,23 @@ export const SetupPage: React.FC = () => {
|
|||||||
toast.warn(t('setup.featuresSaveFailed'));
|
toast.warn(t('setup.featuresSaveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingFeatures(false);
|
setIsSavingFeatures(false);
|
||||||
// If the chosen features need config the wizard can collect (invoicing,
|
// Event types come next (#800) — the wizard is the one window in which
|
||||||
// email), go to the config step; otherwise enter the app.
|
// the seeded defaults can be freely renamed or deleted, because nothing
|
||||||
|
// (events, quotes, reminder mails) references them yet.
|
||||||
|
setStep('eventTypes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// After the event-types step: if the chosen features need config the wizard
|
||||||
|
// can collect (invoicing, email), go to the config step; otherwise skip to
|
||||||
|
// the final community/thank-you step (#732), whose Finish enters the app.
|
||||||
|
const continueAfterEventTypes = () => {
|
||||||
const needsConfig =
|
const needsConfig =
|
||||||
selectedFeatures.has('bills') ||
|
selectedFeatures.has('bills') ||
|
||||||
selectedFeatures.has('reminderEmails') ||
|
selectedFeatures.has('reminderEmails') ||
|
||||||
selectedFeatures.has('incomingMail') ||
|
selectedFeatures.has('incomingMail') ||
|
||||||
selectedFeatures.has('whatsapp');
|
selectedFeatures.has('whatsapp');
|
||||||
// Both the config branch and the no-config path end on the final
|
|
||||||
// community/thank-you step (#732), whose Finish button enters the app.
|
|
||||||
setStep(needsConfig ? 'config' : 'community');
|
setStep(needsConfig ? 'config' : 'community');
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
||||||
@@ -278,6 +285,8 @@ export const SetupPage: React.FC = () => {
|
|||||||
? t('setup.tokenStepSubtitle')
|
? t('setup.tokenStepSubtitle')
|
||||||
: step === 'account'
|
: step === 'account'
|
||||||
? t('setup.accountStepSubtitle')
|
? t('setup.accountStepSubtitle')
|
||||||
|
: step === 'eventTypes'
|
||||||
|
? t('setup.eventTypes.subtitle')
|
||||||
: step === 'restore'
|
: step === 'restore'
|
||||||
? t('setup.restoreStepSubtitle')
|
? t('setup.restoreStepSubtitle')
|
||||||
: step === 'config'
|
: step === 'config'
|
||||||
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
|
|||||||
{t('setup.back')}
|
{t('setup.back')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : step === 'eventTypes' ? (
|
||||||
|
<SetupEventTypesStep onDone={continueAfterEventTypes} />
|
||||||
) : step === 'config' ? (
|
) : step === 'config' ? (
|
||||||
<SetupConfigStep
|
<SetupConfigStep
|
||||||
selectedFeatures={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
@@ -546,7 +557,14 @@ export const SetupPage: React.FC = () => {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
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={<ArrowRight className="w-4 h-4" />}
|
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{t('setup.community.finish')}
|
{t('setup.community.finish')}
|
||||||
|
|||||||
@@ -38,4 +38,11 @@ export const setupService = {
|
|||||||
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
|
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
|
||||||
return response.data;
|
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<void> {
|
||||||
|
await api.post('/setup/complete');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user