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:
lorentz 2026-05-10 07:41:27 -04:00
parent 577e236eb5
commit 7f4ffa0fb6
3 changed files with 550 additions and 0 deletions

View 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>
);
}

View file

@ -0,0 +1,88 @@
/**
* Admin API single notify_event_key update and delete.
* PUT: update display_label, description, sort_order, is_active by key.
* DELETE: hard delete one event key by primary key.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
function toRow(row: Record<string, unknown>) {
return {
key: row.key,
displayLabel: row.display_label,
description: row.description,
sortOrder: row.sort_order,
isActive: row.is_active,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
const { error } = await requireAdmin();
if (error) return error;
try {
const { key } = await params;
const body = await request.json();
const { display_label, description, sort_order, is_active } = body;
const result = await postgresClient.query(
`UPDATE notify_event_keys
SET display_label = COALESCE($1, display_label),
description = COALESCE($2, description),
sort_order = COALESCE($3, sort_order),
is_active = COALESCE($4, is_active),
updated_at = NOW()
WHERE key = $5
RETURNING key, display_label, description, sort_order, is_active, created_at, updated_at`,
[display_label ?? null, description ?? null, sort_order ?? null, is_active ?? null, key]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Event key not found' }, { status: 404 });
}
return NextResponse.json({ data: toRow(result.rows[0]) });
} catch (err) {
console.error('PUT /api/admin/notify-event-keys/[key] failed:', err);
return NextResponse.json(
{ error: 'Failed to update event key', message: err instanceof Error ? err.message : 'unknown' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
const { error } = await requireAdmin();
if (error) return error;
try {
const { key } = await params;
const result = await postgresClient.query(
`DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`,
[key]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Event key not found' }, { status: 404 });
}
return NextResponse.json({ deleted: true, key: result.rows[0].key });
} catch (err) {
console.error('DELETE /api/admin/notify-event-keys/[key] failed:', err);
return NextResponse.json(
{ error: 'Failed to delete event key', message: err instanceof Error ? err.message : 'unknown' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,108 @@
/**
* Admin API notify_event_keys list and create.
* GET: list all event keys ordered by sort_order, key.
* POST: create a new event key with key regex validation.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
const KEY_REGEX = /^[a-z][a-z0-9_]*$/i;
function toRow(row: Record<string, unknown>) {
return {
key: row.key,
displayLabel: row.display_label,
description: row.description,
sortOrder: row.sort_order,
isActive: row.is_active,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function GET() {
const { error } = await requireAdmin();
if (error) return error;
try {
const result = await postgresClient.query(
`SELECT key, display_label, description, sort_order, is_active, created_at, updated_at
FROM notify_event_keys
ORDER BY sort_order ASC, key ASC`
);
return NextResponse.json({ data: result.rows.map(toRow) });
} catch (err) {
console.error('GET /api/admin/notify-event-keys failed:', err);
return NextResponse.json(
{ error: 'Failed to fetch event keys', message: err instanceof Error ? err.message : 'unknown' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
const { error } = await requireAdmin();
if (error) return error;
try {
const body = await request.json();
const { key, display_label, description, sort_order, is_active } = body;
if (!key || typeof key !== 'string' || key.length > 128) {
return NextResponse.json(
{ error: 'key must be a non-empty string of at most 128 characters' },
{ status: 400 }
);
}
if (!KEY_REGEX.test(key)) {
return NextResponse.json(
{ error: 'key must match /^[a-z][a-z0-9_]*$/i' },
{ status: 400 }
);
}
if (!display_label || typeof display_label !== 'string' || display_label.length > 200) {
return NextResponse.json(
{ error: 'display_label must be a non-empty string of at most 200 characters' },
{ status: 400 }
);
}
// INSERT with ON CONFLICT DO NOTHING; if key already exists return 409
await postgresClient.query(
`INSERT INTO notify_event_keys (key, display_label, description, sort_order, is_active)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (key) DO NOTHING`,
[key, display_label, description ?? null, sort_order ?? 0, is_active ?? true]
);
const result = await postgresClient.query(
`SELECT key, display_label, description, sort_order, is_active, created_at, updated_at
FROM notify_event_keys WHERE key = $1`,
[key]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Conflict — key already exists' }, { status: 409 });
}
// Determine if we just inserted or it already existed
// If the created_at is very recent (within 1 second) we inserted it
const row = result.rows[0];
const createdAt = new Date(row.created_at as string).getTime();
const now = Date.now();
if (now - createdAt > 2000) {
// Row existed before this request
return NextResponse.json({ error: 'Conflict — key already exists' }, { status: 409 });
}
return NextResponse.json({ data: toRow(row) }, { status: 201 });
} catch (err) {
console.error('POST /api/admin/notify-event-keys failed:', err);
return NextResponse.json(
{ error: 'Failed to create event key', message: err instanceof Error ? err.message : 'unknown' },
{ status: 500 }
);
}
}