Audit-log rows with user-FK diffs (assignedTo, ownerId, reassignedTo, createdBy, addedBy, changedBy, transferredBy) previously rendered the raw user UUID in the activity feed (e.g. "→ mEcsLxo5kyFMyhbOSehxJjY…"). Same gap on the row's actor — the rep had no idea who did what. - getRecentActivity collects all userIds referenced by either the row's actor (auditLogs.userId) or by user-FK diff values, then bulk-fetches user_profiles in a single query. Output rows now carry an `actorName` field and have their `oldValue`/`newValue` swapped for display names on user-FK fields. - Unknown / deleted users fall back to "Unknown user (#short-uuid)" so the audit trail stays useful for forensics. - ActivityItem client type extended with `actorName`. Existing consumers still read the raw `userId` for forensics + deep-link. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
10 KiB
TypeScript
288 lines
10 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 {
|
|
STAGE_LABELS,
|
|
PIPELINE_STAGES,
|
|
LEGACY_STAGE_REMAP,
|
|
formatSource,
|
|
type PipelineStage,
|
|
} from '@/lib/constants';
|
|
|
|
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;
|
|
}
|
|
|
|
/** camelCase / snake_case field name → "Title Case" so the audit log
|
|
* reads naturally ("fullName" → "Full Name", "phone_number" → "Phone
|
|
* Number"). Single-word fields stay capitalized. */
|
|
function humanizeFieldName(name: string): string {
|
|
return name
|
|
.replace(/_/g, ' ')
|
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
/** Map enum-typed field values to their canonical human labels. The audit
|
|
* log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the
|
|
* feed should read like `10% Deposit`, not the wire value. */
|
|
function normalizeEnumValue(field: string, value: unknown): unknown {
|
|
if (typeof value !== 'string') return value;
|
|
const f = field.replace(/_/g, '').toLowerCase();
|
|
if (f === 'pipelinestage' || f === 'stage') {
|
|
// A2: map legacy 9-stage enum values to their 7-stage equivalents so
|
|
// historical audit-log rows ("deposit_10pct", "contract_sent", ...)
|
|
// render as the modern label rather than a humanized raw enum.
|
|
const modern = (PIPELINE_STAGES as readonly string[]).includes(value)
|
|
? (value as PipelineStage)
|
|
: LEGACY_STAGE_REMAP[value];
|
|
if (modern) return STAGE_LABELS[modern];
|
|
return humanizeFieldName(value);
|
|
}
|
|
if (f === 'source') {
|
|
return formatSource(value) ?? value;
|
|
}
|
|
if (f === 'leadcategory' || f === 'category') {
|
|
return humanizeFieldName(value);
|
|
}
|
|
if (f === 'outcome') {
|
|
return humanizeFieldName(value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Render a JSON-ish value as a short, single-line preview. Strings come
|
|
* through as-is; objects flatten to "k: v, k: v"; arrays compress to a
|
|
* count; nulls / empty render as em-dash. */
|
|
function shortValue(value: unknown, fieldContext?: string): string {
|
|
if (fieldContext) value = normalizeEnumValue(fieldContext, value);
|
|
if (value === null || value === undefined || value === '') return '—';
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
|
|
if (typeof value === 'object') {
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
if (entries.length === 0) return '—';
|
|
return entries
|
|
.slice(0, 3)
|
|
.map(
|
|
([k, v]) =>
|
|
`${humanizeFieldName(k)}: ${typeof v === 'string' ? normalizeEnumValue(k, v) : JSON.stringify(v)}`,
|
|
)
|
|
.join(', ');
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
/** Build a "Field: old → new" diff string for the activity row's second
|
|
* line. Returns null when there's nothing useful to show.
|
|
*
|
|
* Audit logs for updates store the per-field diff inside `oldValue` as
|
|
* `{ field: { old, new }, … }` (see entity-diff.ts), so that's the
|
|
* shape we pattern-match first. Falls back to a fieldChanged/old→new
|
|
* pair when those are present, and finally to a key-by-key compare of
|
|
* two flat objects in `oldValue` vs `newValue`. */
|
|
function buildDiffLine(item: ActivityItem): string | null {
|
|
// Shape A: oldValue = { field: { old, new }, … }
|
|
if (
|
|
item.action === 'update' &&
|
|
item.oldValue &&
|
|
typeof item.oldValue === 'object' &&
|
|
!Array.isArray(item.oldValue)
|
|
) {
|
|
const diffMap = item.oldValue as Record<string, unknown>;
|
|
const entries = Object.entries(diffMap).filter(([, v]) => {
|
|
return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object);
|
|
});
|
|
if (entries.length > 0) {
|
|
return entries
|
|
.slice(0, 2)
|
|
.map(([field, v]) => {
|
|
const { old, new: nextValue } = v as { old: unknown; new: unknown };
|
|
return `${humanizeFieldName(field)}: ${shortValue(old, field)} → ${shortValue(nextValue, field)}`;
|
|
})
|
|
.join(' · ');
|
|
}
|
|
}
|
|
|
|
// Shape B: single-field change with explicit columns.
|
|
if (item.fieldChanged) {
|
|
const field = item.fieldChanged;
|
|
return `${humanizeFieldName(field)}: ${shortValue(item.oldValue, field)} → ${shortValue(item.newValue, field)}`;
|
|
}
|
|
|
|
// Shape C: flat oldValue vs flat newValue.
|
|
if (
|
|
item.action === 'update' &&
|
|
item.oldValue &&
|
|
typeof item.oldValue === 'object' &&
|
|
item.newValue &&
|
|
typeof item.newValue === 'object'
|
|
) {
|
|
const oldObj = item.oldValue as Record<string, unknown>;
|
|
const newObj = item.newValue as Record<string, unknown>;
|
|
const keys = Object.keys(oldObj).filter((k) => k in newObj);
|
|
if (keys.length === 0) return null;
|
|
return keys
|
|
.slice(0, 2)
|
|
.map(
|
|
(k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)} → ${shortValue(newObj[k], k)}`,
|
|
)
|
|
.join(' · ');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
create: 'default',
|
|
update: 'secondary',
|
|
delete: 'destructive',
|
|
archive: 'outline',
|
|
restore: 'secondary',
|
|
};
|
|
|
|
function ActionBadge({ action }: { action: string }) {
|
|
const variant = ACTION_VARIANTS[action] ?? 'outline';
|
|
return (
|
|
<Badge variant={variant} className="shrink-0 capitalize text-xs">
|
|
{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">
|
|
{item.entityType}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="font-medium capitalize">{item.entityType}</span>
|
|
{item.entityId && (
|
|
<span className="ml-1 text-muted-foreground font-mono text-xs">
|
|
{item.entityId.slice(0, 8)}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</p>
|
|
{diffLine ? (
|
|
<p className="truncate text-xs text-muted-foreground mt-0.5" title={diffLine}>
|
|
{diffLine}
|
|
</p>
|
|
) : null}
|
|
<p className="text-[11px] text-muted-foreground/80 mt-0.5">
|
|
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
return (
|
|
<WidgetErrorBoundary>
|
|
<ActivityFeedInner />
|
|
</WidgetErrorBoundary>
|
|
);
|
|
}
|