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
@@ -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();
};
+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,
};
@@ -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';
+13
View File
@@ -3611,6 +3611,19 @@
"finish": "Einrichtung 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": {
"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.",
+13
View File
@@ -3500,6 +3500,19 @@
"finish": "Finish setup",
"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": {
"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.",
+37 -19
View File
@@ -11,6 +11,7 @@ import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
@@ -67,7 +68,7 @@ export const SetupPage: React.FC = () => {
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 [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -241,19 +242,25 @@ export const SetupPage: React.FC = () => {
toast.warn(t('setup.featuresSaveFailed'));
} finally {
setIsSavingFeatures(false);
// If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
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');
// Event types come next (#800) — the wizard is the one window in which
// 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 =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
setStep(needsConfig ? 'config' : 'community');
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
return (
@@ -278,13 +285,15 @@ export const SetupPage: React.FC = () => {
? t('setup.tokenStepSubtitle')
: step === 'account'
? t('setup.accountStepSubtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
: step === 'eventTypes'
? t('setup.eventTypes.subtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
{t('setup.back')}
</Button>
</div>
) : step === 'eventTypes' ? (
<SetupEventTypesStep onDone={continueAfterEventTypes} />
) : step === 'config' ? (
<SetupConfigStep
selectedFeatures={selectedFeatures}
@@ -546,7 +557,14 @@ export const SetupPage: React.FC = () => {
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={<ArrowRight className="w-4 h-4" />}
>
{t('setup.community.finish')}
+7
View File
@@ -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<void> {
await api.post('/setup/complete');
},
};