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} */ @@ -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} - 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 }; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index d2fd5dc5..91f7d906 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -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 diff --git a/backend/src/services/setupService.js b/backend/src/services/setupService.js index 19cf55ef..8faaca19 100644 --- a/backend/src/services/setupService.js +++ b/backend/src/services/setupService.js @@ -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, +}; diff --git a/frontend/src/components/admin/SetupEventTypesStep.tsx b/frontend/src/components/admin/SetupEventTypesStep.tsx new file mode 100644 index 00000000..c2546949 --- /dev/null +++ b/frontend/src/components/admin/SetupEventTypesStep.tsx @@ -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 = ({ onDone }) => { + const { t } = useTranslation(); + const [rows, setRows] = useState(null); + const [original, setOriginal] = useState>(new Map()); + const [deletedIds, setDeletedIds] = useState([]); + 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) => { + 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. +
+

{t('setup.eventTypes.loadFailed')}

+ +
+ ) : ( + + ); + } + + return ( +
+

+ {t('setup.eventTypes.intro')} +

+ +
+ {rows.map((row, index) => ( +
+ +
+ setRow(index, { name: e.target.value })} + placeholder={t('setup.eventTypes.namePlaceholder')} + aria-label={t('setup.eventTypes.nameLabel')} + /> +
+
+ setRow(index, { slug_prefix: normalizeSlug(e.target.value) })} + placeholder={t('setup.eventTypes.slugPlaceholder')} + aria-label={t('setup.eventTypes.slugLabel')} + /> +
+ +
+ ))} +
+ + + +

{t('setup.eventTypes.hint')}

+ + +
+ ); +}; + +SetupEventTypesStep.displayName = 'SetupEventTypesStep'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 95ab5f10..05d9e252 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8402084f..6b23f329 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 12af1f46..70459423 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -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')}

{(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'); + }, };