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()
This commit is contained in:
parent
577e236eb5
commit
7f4ffa0fb6
3 changed files with 550 additions and 0 deletions
354
app/admin/workflow/event-keys/page.tsx
Normal file
354
app/admin/workflow/event-keys/page.tsx
Normal file
|
|
@ -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<EventKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(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<EditForm>({ 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 (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/workflow">
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Key className="h-6 w-6" /> Event Keys
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage the notification event key lookup (humanization layer — unknown keys are still routable)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" /> New event key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">New Event Key</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-key">Key <span className="text-muted-foreground text-xs">(e.g. ticket_assigned_to_me)</span></Label>
|
||||
<Input
|
||||
id="new-key"
|
||||
placeholder="ticket_assigned_to_me"
|
||||
value={newKey}
|
||||
onChange={e => setNewKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-label">Display Label</Label>
|
||||
<Input
|
||||
id="new-label"
|
||||
placeholder="Ticket Assigned to Me"
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-desc">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="new-desc"
|
||||
placeholder="Brief description of when this event fires"
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-32">
|
||||
<Label htmlFor="new-sort">Sort Order</Label>
|
||||
<Input
|
||||
id="new-sort"
|
||||
type="number"
|
||||
value={newSortOrder}
|
||||
onChange={e => setNewSortOrder(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createEventKey} disabled={!newKey || !newLabel || isSaving}>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" /> Loading event keys...
|
||||
</div>
|
||||
) : eventKeys.length === 0 && !showCreate ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Key className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p>No event keys configured yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{eventKeys.map(ek => (
|
||||
<Card key={ek.key}>
|
||||
<CardContent className="py-4">
|
||||
{editingKey === ek.key ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Display Label</Label>
|
||||
<Input
|
||||
value={editForm.displayLabel}
|
||||
onChange={e => setEditForm(f => ({ ...f, displayLabel: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-32">
|
||||
<Label>Sort Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editForm.sortOrder}
|
||||
onChange={e => setEditForm(f => ({ ...f, sortOrder: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={editForm.description}
|
||||
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => saveEdit(ek.key)} disabled={isSaving}>
|
||||
{isSaving ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingKey(null)}>
|
||||
<X className="h-3 w-3 mr-1" /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
checked={ek.isActive}
|
||||
onCheckedChange={checked => toggleActive(ek.key, checked)}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{ek.displayLabel}</span>
|
||||
<Badge variant="outline" className="font-mono text-xs">{ek.key}</Badge>
|
||||
{!ek.isActive && <Badge variant="secondary">Inactive</Badge>}
|
||||
<span className="text-xs text-muted-foreground">order: {ek.sortOrder}</span>
|
||||
</div>
|
||||
{ek.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{ek.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => startEdit(ek)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => deleteEventKey(ek.key)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue