- 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>
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
'use client';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import Link from 'next/link';
|
|
import { useParams } from 'next/navigation';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
|
import { usePermissions } from '@/hooks/use-permissions';
|
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
|
import {
|
|
actionVariant,
|
|
actionVerb,
|
|
buildDiffLine,
|
|
humanizeEntityType,
|
|
} from '@/components/shared/activity-formatting';
|
|
|
|
interface ActivityItem {
|
|
id: string;
|
|
action: string;
|
|
entityType: string;
|
|
entityId: string | null;
|
|
/** Server-resolved human label (client name, yacht name, …) when the
|
|
* underlying entity still exists. Falls back to the id prefix in the UI. */
|
|
label: string | null;
|
|
userId: string | null;
|
|
/** Server-resolved actor display name (from user_profiles). When null,
|
|
* the actor row no longer exists - render falls back to a "Unknown
|
|
* user" sentinel rather than the raw UUID prefix. */
|
|
actorName: string | null;
|
|
fieldChanged: string | null;
|
|
/** For user-FK diff rows (assignedTo, ownerId, etc.) the service
|
|
* already replaces these with display names. Non-user-FK rows pass
|
|
* through verbatim. */
|
|
oldValue: unknown;
|
|
newValue: unknown;
|
|
metadata: Record<string, unknown> | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
function ActionBadge({ action }: { action: string }) {
|
|
return (
|
|
<Badge variant={actionVariant(action)} className="shrink-0 capitalize text-xs">
|
|
{actionVerb(action)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function ActivityFeedInner() {
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
const { can } = usePermissions();
|
|
const canViewAuditLog = can('admin', 'view_audit_log');
|
|
|
|
const { data, isLoading } = useQuery<ActivityItem[]>({
|
|
queryKey: ['dashboard', 'activity'],
|
|
queryFn: () => apiFetch<ActivityItem[]>('/api/v1/dashboard/activity'),
|
|
staleTime: 30_000,
|
|
retry: 2,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return <CardSkeleton />;
|
|
}
|
|
|
|
// A1: permission_denied rows on the activity feed render as a bare
|
|
// action badge with no entity name (they target `admin.X` with empty
|
|
// entityId). They're noise for the rep - keep them in the audit log
|
|
// page but hide them from the dashboard feed.
|
|
const items = (data ?? []).filter((i) => i.action !== 'permission_denied');
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
|
<CardTitle className="text-base">Recent Activity</CardTitle>
|
|
{canViewAuditLog && portSlug ? (
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/${portSlug}/admin/audit` as any}
|
|
className="text-xs font-medium text-primary hover:underline"
|
|
>
|
|
See all
|
|
</Link>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{items.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No recent activity yet - your team's actions (interests created, stages changed,
|
|
invoices sent) will appear here.
|
|
</p>
|
|
) : (
|
|
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
|
{items.map((item) => {
|
|
const diffLine = buildDiffLine(item);
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
|
>
|
|
<ActionBadge action={item.action} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-foreground">
|
|
{item.label ? (
|
|
<>
|
|
<span className="font-medium">{item.label}</span>
|
|
{/* M-NEW-2: explicit middle-dot separator. The
|
|
prior `ml-1.5` was getting collapsed under
|
|
`truncate` so the label + type rendered as
|
|
"Test Person 1interest" with no visible
|
|
space between them. */}
|
|
<span className="text-muted-foreground/60 mx-1.5">·</span>
|
|
<span className="text-muted-foreground text-xs capitalize">
|
|
{humanizeEntityType(item.entityType)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
// No resolvable label - either the entity was
|
|
// deleted or the type isn't in the server-side
|
|
// resolver yet. Either way we never expose a
|
|
// UUID fragment: it reads as noise to the rep
|
|
// and leaks an internal identifier.
|
|
<span className="font-medium capitalize">
|
|
{humanizeEntityType(item.entityType)}
|
|
{item.entityId ? (
|
|
<span className="ml-1 text-muted-foreground text-xs font-normal">
|
|
(removed)
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
)}
|
|
</p>
|
|
{diffLine ? (
|
|
<p className="truncate text-xs text-muted-foreground mt-0.5" title={diffLine}>
|
|
{diffLine}
|
|
</p>
|
|
) : null}
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
return (
|
|
<WidgetErrorBoundary>
|
|
<ActivityFeedInner />
|
|
</WidgetErrorBoundary>
|
|
);
|
|
}
|