feat(uat-p5): activity-feed module, signing-order tri-state, webhook health card
- Activity-feed: shared formatting module (src/components/shared/activity-formatting.ts) centralises action verbs, badge variants, entity-type labels, enum-value normalisation, shortValue, and buildDiffLine. The dashboard widget feed and the per-entity audit feed now both consume it - duplicate ~250 lines collapsed, vocabularies aligned, badge palette unified. - Signing order setting becomes tri-state. The new TEMPLATE_DEFAULT value (the new default) skips overriding the template's own signingOrder so each Documenso template's stored setting wins. PARALLEL / SEQUENTIAL keep forcing the override. - Admin Documenso page now ships a Webhook health card backed by /api/v1/admin/documenso-webhook/health (secret status, expected URL, last received event, recent secret rejections) and a "Test now" button that fires a synthetic DOCUMENT_OPENED through /api/v1/admin/documenso-webhook/test against the local receiver to verify the full pipeline without driving a real Documenso event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
178
src/components/admin/documenso/webhook-health-card.tsx
Normal file
178
src/components/admin/documenso/webhook-health-card.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Activity, AlertTriangle, CheckCircle2, Loader2, PlayCircle } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface HealthResponse {
|
||||
data: {
|
||||
secretConfigured: boolean;
|
||||
secretSource: 'port' | 'env' | null;
|
||||
expectedUrl: string;
|
||||
lastReceived: {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
action: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
} | null;
|
||||
lastFailed: {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestResponse {
|
||||
data: {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
elapsedMs: number;
|
||||
response: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Documenso webhook health card. Surfaces whether the inbound webhook
|
||||
* pipeline is wired correctly: secret configured, last event received,
|
||||
* last secret-mismatch (if any). The "Test now" button fires a
|
||||
* synthetic webhook against the local receiver so reps can confirm the
|
||||
* full pipeline without having to drive a real Documenso event.
|
||||
*/
|
||||
export function WebhookHealthCard() {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const { data, isLoading, refetch } = useQuery<HealthResponse>({
|
||||
queryKey: ['admin', 'documenso-webhook', 'health'],
|
||||
queryFn: () => apiFetch<HealthResponse>('/api/v1/admin/documenso-webhook/health'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await apiFetch<TestResponse>('/api/v1/admin/documenso-webhook/test', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
});
|
||||
if (res.data.ok) {
|
||||
toast.success(
|
||||
`Test webhook delivered (${res.data.elapsedMs}ms, status ${res.data.status})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`Receiver rejected the test - status ${res.data.status}`);
|
||||
}
|
||||
void refetch();
|
||||
} catch (err) {
|
||||
toastError(err, 'Failed to send test webhook');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="h-4 w-4" aria-hidden />
|
||||
Webhook health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
) : data?.data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Secret
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1.5">
|
||||
{data.data.secretConfigured ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" aria-hidden />
|
||||
)}
|
||||
{data.data.secretConfigured ? 'Configured' : 'Not set'}
|
||||
{data.data.secretSource ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
from {data.data.secretSource}
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Expected URL
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">{data.data.expectedUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Last received
|
||||
</p>
|
||||
{data.data.lastReceived ? (
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{data.data.lastReceived.action}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNowStrict(new Date(data.data.lastReceived.receivedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No inbound webhook events yet. Click "Test now" below to fire one
|
||||
through the receiver.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.data.lastFailed ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 text-sm dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||
<p className="flex items-center gap-1.5 font-medium text-amber-900 dark:text-amber-100">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden />
|
||||
Recent secret rejection
|
||||
</p>
|
||||
<p className="mt-1 text-amber-800 dark:text-amber-200">
|
||||
A webhook delivery was rejected{' '}
|
||||
{formatDistanceToNowStrict(new Date(data.data.lastFailed.receivedAt), {
|
||||
addSuffix: true,
|
||||
})}{' '}
|
||||
because the secret didn't match. If you just rotated it, update the matching
|
||||
value in Documenso's webhook config.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Button onClick={handleTest} disabled={testing || !data.data.secretConfigured}>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<PlayCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Test now
|
||||
</Button>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Fires a synthetic DOCUMENT_OPENED event against the receiver to verify the full
|
||||
pipeline (secret check, parse, dedup, audit-log). Safe to run anytime - no document
|
||||
state is changed.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user