From 7f4ffa0fb665f5d1e9fc22474e2cc948e631c138 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 10 May 2026 07:41:27 -0400 Subject: [PATCH] feat(09-06): add /admin/workflow/event-keys CRUD page and API routes - GET/POST /api/admin/notify-event-keys: list ordered by sort_order/key, create with key regex validation (^[a-z][a-z0-9_]*$/i), 409 on conflict - PUT/DELETE /api/admin/notify-event-keys/[key]: update via COALESCE, hard delete with 404 guard - app/admin/workflow/event-keys/page.tsx: list with inline edit, Switch for is_active toggle, + New event key form, sonner toasts - All routes gated by requireAdmin() --- app/admin/workflow/event-keys/page.tsx | 354 ++++++++++++++++++ .../admin/notify-event-keys/[key]/route.ts | 88 +++++ app/api/admin/notify-event-keys/route.ts | 108 ++++++ 3 files changed, 550 insertions(+) create mode 100644 app/admin/workflow/event-keys/page.tsx create mode 100644 app/api/admin/notify-event-keys/[key]/route.ts create mode 100644 app/api/admin/notify-event-keys/route.ts diff --git a/app/admin/workflow/event-keys/page.tsx b/app/admin/workflow/event-keys/page.tsx new file mode 100644 index 0000000..ab342b2 --- /dev/null +++ b/app/admin/workflow/event-keys/page.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + ArrowLeft, + Plus, + Trash2, + Pencil, + Key, + Loader2, + Save, + X, +} from 'lucide-react'; + +interface EventKey { + key: string; + displayLabel: string; + description: string | null; + sortOrder: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +interface EditForm { + displayLabel: string; + description: string; + sortOrder: string; +} + +export default function EventKeysPage() { + const [eventKeys, setEventKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [editingKey, setEditingKey] = useState(null); + + // New event key form state + const [newKey, setNewKey] = useState(''); + const [newLabel, setNewLabel] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newSortOrder, setNewSortOrder] = useState('0'); + const [isSaving, setIsSaving] = useState(false); + + // Inline edit state + const [editForm, setEditForm] = useState({ displayLabel: '', description: '', sortOrder: '0' }); + + useEffect(() => { loadEventKeys(); }, []); + + const loadEventKeys = async () => { + setIsLoading(true); + try { + const res = await fetch('/api/admin/notify-event-keys'); + if (!res.ok) { + toast.error('Failed to load event keys'); + return; + } + const data = await res.json(); + setEventKeys(data.data || []); + } catch (err) { + console.error('Failed to load event keys:', err); + toast.error('Failed to load event keys'); + } finally { + setIsLoading(false); + } + }; + + const createEventKey = async () => { + if (!newKey || !newLabel) return; + setIsSaving(true); + try { + const res = await fetch('/api/admin/notify-event-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key: newKey, + display_label: newLabel, + description: newDescription || null, + sort_order: parseInt(newSortOrder, 10) || 0, + }), + }); + if (res.status === 409) { + toast.error('An event key with that key name already exists'); + return; + } + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || 'Failed to create event key'); + return; + } + toast.success('Event key created'); + setNewKey(''); + setNewLabel(''); + setNewDescription(''); + setNewSortOrder('0'); + setShowCreate(false); + loadEventKeys(); + } catch (err) { + console.error('Failed to create event key:', err); + toast.error('Failed to create event key'); + } finally { + setIsSaving(false); + } + }; + + const startEdit = (ek: EventKey) => { + setEditingKey(ek.key); + setEditForm({ + displayLabel: ek.displayLabel, + description: ek.description ?? '', + sortOrder: String(ek.sortOrder), + }); + }; + + const saveEdit = async (key: string) => { + setIsSaving(true); + try { + const res = await fetch(`/api/admin/notify-event-keys/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + display_label: editForm.displayLabel || undefined, + description: editForm.description || null, + sort_order: parseInt(editForm.sortOrder, 10) || 0, + }), + }); + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || 'Failed to update event key'); + return; + } + toast.success('Event key updated'); + setEditingKey(null); + loadEventKeys(); + } catch (err) { + console.error('Failed to update event key:', err); + toast.error('Failed to update event key'); + } finally { + setIsSaving(false); + } + }; + + const toggleActive = async (key: string, isActive: boolean) => { + try { + const res = await fetch(`/api/admin/notify-event-keys/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: isActive }), + }); + if (!res.ok) { + toast.error('Failed to toggle event key'); + return; + } + setEventKeys(prev => prev.map(ek => ek.key === key ? { ...ek, isActive } : ek)); + } catch (err) { + console.error('Failed to toggle event key:', err); + toast.error('Failed to toggle event key'); + } + }; + + const deleteEventKey = async (key: string) => { + if (!confirm(`Delete event key "${key}"? This cannot be undone.`)) return; + try { + const res = await fetch(`/api/admin/notify-event-keys/${encodeURIComponent(key)}`, { + method: 'DELETE', + }); + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || 'Failed to delete event key'); + return; + } + toast.success(`Event key "${key}" deleted`); + setEventKeys(prev => prev.filter(ek => ek.key !== key)); + } catch (err) { + console.error('Failed to delete event key:', err); + toast.error('Failed to delete event key'); + } + }; + + return ( +
+
+
+ + + +
+

+ Event Keys +

+

+ Manage the notification event key lookup (humanization layer — unknown keys are still routable) +

+
+
+ +
+ + {showCreate && ( + + + New Event Key + + +
+
+ + setNewKey(e.target.value)} + /> +
+
+ + setNewLabel(e.target.value)} + /> +
+
+
+ +