fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response: - isValidEventType: live catalog is authoritative when it has rows — a deleted or deactivated slug no longer validates via the legacy fallback (fallback now only serves an empty-catalog install) - deleteEventType: refuse deleting the last (and last ACTIVE) type; updateEventType: refuse deactivating the last active type (unknown slugs are rejected since the validator change, so an empty active catalog would brick event creation) - setup window fails closed: only an explicit stored `false` opens it (a portable-backup restore can leave the key absent) and a normal admin login durably closes it (abandoned-wizard case) - reserved bootstrap keys (setup_wizard_completed, setup_token) are stripped from ALL generic settings upserts (/general, /security, /analytics, /seo) so the marker is genuinely one-way - wizard step: deletes ordered so the catalog can never end up empty, and a genuinely failed system-type deletion reloads the list and stays on the step instead of advancing past the only window in which it can be retried - CreateEventPage: snap the hardcoded initial 'wedding' selection to the first active type when the catalog no longer contains it - v1 API: new GET /event-types (read scope) so token clients can discover valid slugs; OpenAPI enum replaced with the live-catalog description
This commit is contained in:
@@ -66,9 +66,16 @@ export const SetupEventTypesStep: React.FC<Props> = ({ 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<Props> = ({ 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user