diff --git a/backend/__tests__/integration/eventTypeSetupWindow.test.js b/backend/__tests__/integration/eventTypeSetupWindow.test.js index 3f43d3f6..933c4b29 100644 --- a/backend/__tests__/integration/eventTypeSetupWindow.test.js +++ b/backend/__tests__/integration/eventTypeSetupWindow.test.js @@ -75,6 +75,11 @@ describe('event type deletion during the setup window (#800)', () => { expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy(); expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy(); + + // The deleted slug must NOT stay creatable through the legacy fallback — + // the live catalog is authoritative while it has rows. + expect(await eventTypeService.isValidEventType('wedding')).toBe(false); + expect(await eventTypeService.isValidEventType('birthday')).toBe(true); }); it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => { @@ -118,4 +123,28 @@ describe('event type deletion during the setup window (#800)', () => { await db('event_types').where({ id: other.id }).update({ is_active: 1 }); }); + + it('fails closed when the completion marker row is missing', async () => { + // A portable-backup restore can replace app_settings with a set that + // predates migration 161 (which will not rerun) — absence must mean + // "configured instance", never an open deletion window. + await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del(); + expect(await setupService.isSetupWizardCompleted()).toBe(true); + await setupService.markSetupWizardCompleted(); + }); + + it('refuses to delete the last remaining event type', async () => { + // Reduce the catalog to a single custom type via direct db writes (the + // service paths are already covered above), then hit the guard. + const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' }); + await db('events').del(); + await db('event_types').whereNot('id', solo.id).del(); + + await expect(eventTypeService.deleteEventType(solo.id)) + .rejects.toMatchObject({ code: 'LAST_TYPE' }); + + // Deactivating it would empty the ACTIVE catalog just the same. + await expect(eventTypeService.updateEventType(solo.id, { is_active: false })) + .rejects.toMatchObject({ code: 'LAST_ACTIVE' }); + }); }); diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js index f18f4286..c3ef780a 100644 --- a/backend/src/routes/adminEventTypes.js +++ b/backend/src/routes/adminEventTypes.js @@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [ if (error.code === 'NOT_FOUND') { return res.status(404).json({ error: error.message }); } - if (error.code === 'DUPLICATE_SLUG_PREFIX') { + if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') { return res.status(400).json({ error: error.message }); } @@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [ if (error.code === 'NOT_FOUND') { return res.status(404).json({ error: error.message }); } - if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') { + if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') { return res.status(400).json({ error: error.message }); } diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index d2e25168..433a8389 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -31,6 +31,19 @@ const watermarkGeneratorService = require('../services/watermarkGeneratorService const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +// Reserved first-run bootstrap keys — never writable through the generic +// settings upserts in this file: setup_wizard_completed is a one-way marker +// (#800; writing false would reopen system-event-type deletion) and +// setup_token is the first-run bootstrap secret. Every handler that loops +// arbitrary request keys into app_settings must strip these first. +const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token']; +const stripReservedSettingKeys = (settings) => { + for (const key of RESERVED_SETTING_KEYS) { + delete settings[key]; + } + return settings; +}; + // Configure multer for logo uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { @@ -906,7 +919,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, // Update general settings router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { - const settings = { ...req.body }; + const settings = stripReservedSettingKeys({ ...req.body }); let uploadLimitTouched = false; const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); @@ -1017,7 +1030,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req // Update security settings router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { - const settings = req.body; + const settings = stripReservedSettingKeys({ ...req.body }); // Update or insert each setting for (const [key, value] of Object.entries(settings)) { @@ -1055,7 +1068,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re // Update analytics settings router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { - const settings = req.body; + const settings = stripReservedSettingKeys({ ...req.body }); // Validate the provider switch (#663 Phase 1). Reject unknown values // so the dashboard route's factory doesn't have to defensively guard. @@ -1110,7 +1123,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r // Update SEO settings router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { - const settings = req.body; + const settings = stripReservedSettingKeys({ ...req.body }); // Validate seo_blocked_ai_agents is an array of strings if (settings.seo_blocked_ai_agents !== undefined) { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 8915380f..03f9fa21 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -45,6 +45,17 @@ const router = express.Router(); async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) { await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent); + // A normal login means the first-run wizard is over — the wizard never hits + // this route (setup sets its cookie directly). Close the system-event-type + // deletion window durably even when the wizard was abandoned mid-way (#800). + // Best-effort: a failure here must never block a login. + try { + const setupService = require('../services/setupService'); + if (!(await setupService.isSetupWizardCompleted())) { + await setupService.markSetupWizardCompleted(); + } + } catch (_) { /* best-effort */ } + await db('admin_users').where('id', admin.id).update({ last_login: new Date(), last_login_ip: ipAddress diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 7896ab78..0cba9a97 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -81,7 +81,7 @@ const photoUpload = multer({ * event_name: { type: string } * event_type: * type: string - * enum: [wedding, birthday, corporate, other, family] + * description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values." * event_date: { type: string, format: date, nullable: true } * customer_name: { type: string, nullable: true } * customer_email: { type: string, format: email, nullable: true } @@ -455,6 +455,48 @@ router.get( } ); +// ────────────────────────────────────────────────────────────────────────── +// GET /event-types — read (catalog discovery for event creation, #800) +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /event-types: + * get: + * tags: [Events] + * summary: List active event types + * description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them. + * security: [{ bearerAuth: [] }] + * responses: + * 200: + * description: Active event types + * content: + * application/json: + * schema: + * type: object + * properties: + * eventTypes: + * type: array + * items: + * type: object + * properties: + * slug_prefix: { type: string } + * name: { type: string } + * emoji: { type: string } + */ +router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const types = await db('event_types') + .where('is_active', formatBoolean(true)) + .orderBy('display_order', 'asc') + .select('slug_prefix', 'name', 'emoji'); + res.json({ eventTypes: types }); + } catch (error) { + logger.error('v1 GET /event-types failed', { error: error.message }); + res.status(500).json({ error: 'Failed to list event types' }); + } +}); + // ────────────────────────────────────────────────────────────────────────── // GET /events/:id — read // ────────────────────────────────────────────────────────────────────────── diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index 9cbbb6f5..d3c31630 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -67,13 +67,20 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => { const isValidEventType = async (slugPrefix) => { const normalized = slugPrefix.toLowerCase(); - // Check in database + // The live catalog is authoritative: a row decides by its active flag, and + // a slug the admin deleted (setup wizard, #800) or deactivated must NOT + // sneak back in through the legacy list below. const eventType = await getEventTypeBySlugPrefix(normalized); - if (eventType && eventType.is_active) { - return true; + if (eventType) { + return Boolean(eventType.is_active); + } + const anyType = await db('event_types').first('id'); + if (anyType) { + return false; } - // Legacy fallback: Accept old hardcoded values for backward compatibility + // Legacy fallback: only for a degenerate install with an EMPTY catalog + // (pre-catalog schema drift) — accept the old hardcoded values. const legacyTypes = ['wedding', 'birthday', 'corporate', 'other']; return legacyTypes.includes(normalized); }; @@ -198,6 +205,19 @@ const updateEventType = async (id, updates) => { } if (updates.is_active !== undefined) { + // Deactivating the last active type would empty the ACTIVE catalog and + // brick event creation (unknown slugs are rejected since #800). + if (updates.is_active === false && eventType.is_active) { + const otherActive = await db('event_types') + .whereNot('id', id) + .where('is_active', formatBoolean(true)) + .first('id'); + if (!otherActive) { + const error = new Error('Cannot deactivate the last active event type — activate another one first.'); + error.code = 'LAST_ACTIVE'; + throw error; + } + } updateData.is_active = formatBoolean(updates.is_active); } @@ -292,6 +312,28 @@ const deleteEventType = async (id) => { throw error; } + // Never delete the last remaining type — and never delete the last ACTIVE + // one either: event creation and the quote/contract default-type resolution + // both need at least one active catalog entry. + const remaining = await db('event_types').whereNot('id', id).count('id as count').first(); + if (!remaining || parseInt(remaining.count) === 0) { + const error = new Error('Cannot delete the last event type — at least one must remain.'); + error.code = 'LAST_TYPE'; + throw error; + } + if (eventType.is_active) { + const remainingActive = await db('event_types') + .whereNot('id', id) + .where('is_active', formatBoolean(true)) + .count('id as count') + .first(); + if (!remainingActive || parseInt(remainingActive.count) === 0) { + const error = new Error('Cannot delete the last active event type — activate another one first.'); + error.code = 'LAST_TYPE'; + throw error; + } + } + // 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')) { diff --git a/backend/src/services/setupService.js b/backend/src/services/setupService.js index 8faaca19..4a71d55c 100644 --- a/backend/src/services/setupService.js +++ b/backend/src/services/setupService.js @@ -28,7 +28,12 @@ const SETUP_TOKEN_KEY = 'setup_token'; const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed'; async function isSetupWizardCompleted() { - return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) === true; + // Fail closed: only an explicit stored `false` (seeded by migration 161 on + // a fresh, admin-less install) opens the deletion window. A missing row — + // e.g. app_settings replaced by a portable-backup restore that predates the + // migration, which will not rerun — means a configured instance, not a + // first run. + return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false; } async function markSetupWizardCompleted() { diff --git a/frontend/src/components/admin/SetupEventTypesStep.tsx b/frontend/src/components/admin/SetupEventTypesStep.tsx index c2546949..5caae3bd 100644 --- a/frontend/src/components/admin/SetupEventTypesStep.tsx +++ b/frontend/src/components/admin/SetupEventTypesStep.tsx @@ -66,9 +66,16 @@ export const SetupEventTypesStep: React.FC = ({ onDone }) => { 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. + // Apply the diff, then advance. Ordering matters twice over: deletes first + // frees a default's slug for a rename/re-create ("replace Wedding with my + // own 'wedding'"), but deleting everything BEFORE a replacement exists could + // empty the catalog if the creation then fails. So: when at least one + // existing row is kept the catalog can never go empty → delete first; when + // the user replaces ALL types → create first and only delete once at least + // one replacement actually persisted. (The backend additionally refuses + // deleting the last remaining type.) 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()); @@ -78,34 +85,74 @@ export const SetupEventTypesStep: React.FC = ({ onDone }) => { } 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, - }); + let deleteFailures = 0; + let createdOk = 0; + + const applyDeletes = async () => { + for (const id of deletedIds) { + try { + await eventTypesService.deleteEventType(id); + } catch { + deleteFailures += 1; } - } catch { - failures += 1; } + }; + const applyCreatesAndUpdates = async () => { + 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, + }); + createdOk += 1; + } + } catch { + failures += 1; + } + } + }; + + const keptExisting = kept.filter((r) => r.id !== undefined).length; + if (keptExisting > 0) { + await applyDeletes(); + await applyCreatesAndUpdates(); + } else { + await applyCreatesAndUpdates(); + if (deletedIds.length > 0 && createdOk === 0) { + // Every replacement failed — deleting now would empty the catalog. + // Keep the seeded types and stay on the step. + setSaving(false); + toast.error(t('setup.eventTypes.atLeastOne')); + return; + } + await applyDeletes(); } + + // A failed DELETE must not slip past this step: system types are only + // deletable inside this window, so once the wizard finishes the request + // can never be retried. Reload the live catalog and stay for a retry. + if (deleteFailures > 0) { + try { + const types = await eventTypesService.getEventTypes(); + setOriginal(new Map(types.map((et) => [et.id, et]))); + setRows(types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji }))); + } catch { /* keep the local rows if the reload fails */ } + setDeletedIds([]); + setSaving(false); + toast.error(t('setup.eventTypes.deleteFailed')); + return; + } + setSaving(false); if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed')); onDone(); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 05d9e252..e5eb44ba 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3622,7 +3622,8 @@ "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." + "saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.", + "deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort." }, "community": { "subtitle": "Alles bereit", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6b23f329..e7c762e0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3511,7 +3511,8 @@ "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." + "saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.", + "deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept." }, "community": { "subtitle": "You're all set", diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 35a313fc..a922e2e7 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -182,6 +182,18 @@ export const CreateEventPage: React.FC = () => { [eventTypes] ); + // The hardcoded initial form value ('wedding') may not exist in the live + // catalog — the setup wizard can rename or delete the defaults (#800), and + // the backend now rejects unknown slugs. Snap to the first active type; a + // user-picked value is always in the list, so this never fights the user. + useEffect(() => { + if (!availableEventTypes.length) return; + if (!availableEventTypes.some(t => t.value === formData.event_type)) { + setFormData(prev => ({ ...prev, event_type: availableEventTypes[0].value })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableEventTypes, formData.event_type]); + // Fetch default settings const { data: settings } = useQuery({ queryKey: ['admin-settings'],