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:
2026-05-26 22:05:14 +02:00
parent 6caf41651f
commit 909dd44605
9 changed files with 655 additions and 208 deletions

View 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 &quot;Test now&quot; 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&apos;t match. If you just rotated it, update the matching
value in Documenso&apos;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>
);
}