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:
@@ -4,6 +4,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
||||
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
|
||||
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
@@ -205,6 +206,8 @@ export default function DocumensoSettingsPage() {
|
||||
/>
|
||||
|
||||
<EmbeddedSigningCard />
|
||||
|
||||
<WebhookHealthCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
106
src/app/api/v1/admin/documenso-webhook/health/route.ts
Normal file
106
src/app/api/v1/admin/documenso-webhook/health/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, desc, eq, or, isNull } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/documenso-webhook/health
|
||||
*
|
||||
* Surfaces the current state of the inbound Documenso webhook pipeline
|
||||
* for the Documenso admin page's "Webhook health" card. Reads the
|
||||
* port's resolved Documenso config + the most recent webhook events
|
||||
* from audit_logs. Pairs with the `/test` POST that fires a synthetic
|
||||
* webhook through the receiver to verify the full pipeline.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
const [portSecretRow] = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.key, 'documenso_webhook_secret'),
|
||||
or(eq(systemSettings.portId, ctx.portId), isNull(systemSettings.portId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const portWebhookSecret =
|
||||
typeof portSecretRow?.value === 'string' && portSecretRow.value.length > 0
|
||||
? portSecretRow.value
|
||||
: null;
|
||||
const envWebhookSecret = env.DOCUMENSO_WEBHOOK_SECRET ?? null;
|
||||
const effectiveSecret = portWebhookSecret ?? envWebhookSecret;
|
||||
const expectedUrl = `${env.APP_URL?.replace(/\/$/, '') ?? ''}/api/webhooks/documenso`;
|
||||
|
||||
// Most recent successful webhook landing (entityType=webhook_inbound,
|
||||
// action != 'webhook_failed'). The receiver writes one audit row per
|
||||
// canonical event; we surface the latest so reps see "yes, traffic
|
||||
// is flowing" rather than guessing from logs.
|
||||
const [lastReceived] = await db
|
||||
.select({
|
||||
id: auditLogs.id,
|
||||
createdAt: auditLogs.createdAt,
|
||||
action: auditLogs.action,
|
||||
metadata: auditLogs.metadata,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.where(
|
||||
and(eq(auditLogs.entityType, 'webhook_inbound'), eq(auditLogs.entityId, 'documenso')),
|
||||
)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(1);
|
||||
|
||||
// Latest secret-mismatch entry - flags the "Documenso is hitting us
|
||||
// with a wrong secret" failure mode separately from "we haven't
|
||||
// heard anything." Combined with `secretConfigured=false` it
|
||||
// narrows the problem to a misalignment between the secret we
|
||||
// stored and what Documenso is sending.
|
||||
const [lastFailed] = await db
|
||||
.select({
|
||||
id: auditLogs.id,
|
||||
createdAt: auditLogs.createdAt,
|
||||
metadata: auditLogs.metadata,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(auditLogs.entityType, 'webhook_inbound'),
|
||||
eq(auditLogs.entityId, 'documenso'),
|
||||
eq(auditLogs.action, 'webhook_failed'),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
secretConfigured: Boolean(effectiveSecret),
|
||||
secretSource: portWebhookSecret ? 'port' : envWebhookSecret ? 'env' : null,
|
||||
expectedUrl,
|
||||
lastReceived: lastReceived
|
||||
? {
|
||||
id: lastReceived.id,
|
||||
receivedAt: lastReceived.createdAt,
|
||||
action: lastReceived.action,
|
||||
metadata: lastReceived.metadata,
|
||||
}
|
||||
: null,
|
||||
lastFailed: lastFailed
|
||||
? {
|
||||
id: lastFailed.id,
|
||||
receivedAt: lastFailed.createdAt,
|
||||
metadata: lastFailed.metadata,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
91
src/app/api/v1/admin/documenso-webhook/test/route.ts
Normal file
91
src/app/api/v1/admin/documenso-webhook/test/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { ValidationError, errorResponse } from '@/lib/errors';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/documenso-webhook/test
|
||||
*
|
||||
* Fires a synthetic Documenso webhook against the local receiver to
|
||||
* verify the full pipeline: secret check, body parsing, dedup,
|
||||
* audit-log write. Echoes the receiver's response back to the
|
||||
* caller so the admin page can render success/failure inline.
|
||||
*
|
||||
* Body: { event?: string } - defaults to 'DOCUMENT_OPENED' which is
|
||||
* the lowest-impact event (no DB mutations beyond the audit-log
|
||||
* write).
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const [portSecretRow] = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.key, 'documenso_webhook_secret'),
|
||||
or(eq(systemSettings.portId, ctx.portId), isNull(systemSettings.portId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const portWebhookSecret =
|
||||
typeof portSecretRow?.value === 'string' && portSecretRow.value.length > 0
|
||||
? portSecretRow.value
|
||||
: null;
|
||||
const effectiveSecret = portWebhookSecret ?? env.DOCUMENSO_WEBHOOK_SECRET ?? null;
|
||||
if (!effectiveSecret) {
|
||||
throw new ValidationError(
|
||||
'No Documenso webhook secret configured. Set documenso_webhook_secret or DOCUMENSO_WEBHOOK_SECRET first.',
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { event?: string };
|
||||
const event = body.event ?? 'DOCUMENT_OPENED';
|
||||
|
||||
// Synthetic payload that the receiver will accept and audit-log.
|
||||
// documentId is namespaced so a real Documenso doc can never
|
||||
// collide with this test ping.
|
||||
const payload = {
|
||||
event,
|
||||
payload: {
|
||||
id: `test-${Date.now()}`,
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${env.APP_URL?.replace(/\/$/, '') ?? ''}/api/webhooks/documenso`;
|
||||
const startedAt = Date.now();
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': effectiveSecret,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const responseBody = await res.text();
|
||||
let parsed: unknown = responseBody;
|
||||
try {
|
||||
parsed = JSON.parse(responseBody);
|
||||
} catch {
|
||||
// non-JSON response, surface as-is
|
||||
}
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
response: parsed,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,11 @@ 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';
|
||||
actionVariant,
|
||||
actionVerb,
|
||||
buildDiffLine,
|
||||
humanizeEntityType,
|
||||
} from '@/components/shared/activity-formatting';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
@@ -42,189 +41,10 @@ interface ActivityItem {
|
||||
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());
|
||||
}
|
||||
|
||||
/** Entity type alias map for the feed labels. Most types humanize fine
|
||||
* via `humanizeFieldName`, but a few read awkwardly ("Residential
|
||||
* Client" is clearer than the raw enum, notes flatten to their parent). */
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
residential_client: 'Residential client',
|
||||
residential_interest: 'Residential interest',
|
||||
berth_tenancy: 'Berth tenancy',
|
||||
berth_maintenance_log: 'Berth maintenance',
|
||||
berth_recommendation: 'Berth recommendation',
|
||||
client_note: 'Client note',
|
||||
yacht_note: 'Yacht note',
|
||||
company_note: 'Company note',
|
||||
interest_note: 'Interest note',
|
||||
interest_qualification: 'Interest qualification',
|
||||
document_send: 'Document send',
|
||||
document_folder: 'Document folder',
|
||||
document_template: 'Document template',
|
||||
documentTemplate: 'Document template',
|
||||
form_template: 'Form template',
|
||||
report_template: 'Report template',
|
||||
email_account: 'Email account',
|
||||
email_message: 'Email message',
|
||||
user_email_change: 'Email change',
|
||||
custom_field_definition: 'Custom field',
|
||||
custom_field_values: 'Custom field',
|
||||
expense_export: 'Expense export',
|
||||
gdpr_export: 'GDPR export',
|
||||
qualification_criterion: 'Qualification criterion',
|
||||
website_submission: 'Website submission',
|
||||
webhook_inbound: 'Inbound webhook',
|
||||
webhook_delivery: 'Webhook delivery',
|
||||
audit_log: 'Audit log',
|
||||
portal_user: 'Portal user',
|
||||
portal_session: 'Portal session',
|
||||
portal_auth_token: 'Portal token',
|
||||
client_contact: 'Client contact',
|
||||
clientContact: 'Client contact',
|
||||
clientAddress: 'Client address',
|
||||
companyAddress: 'Company address',
|
||||
clientRelationship: 'Client relationship',
|
||||
company_membership: 'Company membership',
|
||||
crm_invite: 'CRM invite',
|
||||
queue_job: 'Queue job',
|
||||
super_admin: 'Super admin',
|
||||
};
|
||||
function humanizeEntityType(type: string): string {
|
||||
return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type);
|
||||
}
|
||||
|
||||
/** 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 variant={actionVariant(action)} className="shrink-0 capitalize text-xs">
|
||||
{actionVerb(action)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
250
src/components/shared/activity-formatting.ts
Normal file
250
src/components/shared/activity-formatting.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Shared formatting helpers for activity-feed surfaces. Centralises the
|
||||
* humanise/diff logic so the dashboard activity widget and per-entity
|
||||
* activity feeds render audit-log rows with the same vocabulary and
|
||||
* shape. Each surface still owns its own JSX; only the strings flow
|
||||
* through this module.
|
||||
*/
|
||||
|
||||
import { STAGE_LABELS, formatSource, type PipelineStage, PIPELINE_STAGES } from '@/lib/constants';
|
||||
|
||||
/** Map legacy 9-stage enum keys to their modern 7-stage counterparts so
|
||||
* historical rows still render with current labels. */
|
||||
const LEGACY_STAGE_REMAP: Record<string, PipelineStage> = {
|
||||
deposit_10pct: 'deposit_paid',
|
||||
contract_sent: 'contract',
|
||||
contract_signed: 'contract',
|
||||
};
|
||||
|
||||
const ACTION_VERBS: Record<string, string> = {
|
||||
create: 'created',
|
||||
update: 'updated',
|
||||
delete: 'deleted',
|
||||
archive: 'archived',
|
||||
restore: 'restored',
|
||||
merge: 'merged',
|
||||
revert: 'reverted',
|
||||
transfer: 'transferred',
|
||||
cancel: 'cancelled',
|
||||
send: 'sent',
|
||||
sign: 'signed',
|
||||
complete: 'completed',
|
||||
reject: 'rejected',
|
||||
enable: 'enabled',
|
||||
disable: 'disabled',
|
||||
pause: 'paused',
|
||||
resume: 'resumed',
|
||||
void: 'voided',
|
||||
permission_denied: 'denied permission for',
|
||||
};
|
||||
|
||||
/** Past-tense verb for an audit `action`. Falls back to the raw enum so
|
||||
* unknown actions still surface (rather than silently dropping). */
|
||||
export function actionVerb(action: string): string {
|
||||
return ACTION_VERBS[action] ?? action;
|
||||
}
|
||||
|
||||
/** Visual variant for the action badge - keeps colours consistent across
|
||||
* every feed surface. */
|
||||
export function actionVariant(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
case 'send':
|
||||
case 'sign':
|
||||
case 'complete':
|
||||
case 'enable':
|
||||
case 'resume':
|
||||
return 'default';
|
||||
case 'update':
|
||||
case 'restore':
|
||||
case 'transfer':
|
||||
case 'merge':
|
||||
return 'secondary';
|
||||
case 'delete':
|
||||
case 'reject':
|
||||
case 'cancel':
|
||||
case 'void':
|
||||
return 'destructive';
|
||||
case 'archive':
|
||||
case 'disable':
|
||||
case 'pause':
|
||||
case 'revert':
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
/** camelCase / snake_case field name -> "Title Case" so the audit log
|
||||
* reads naturally ("fullName" -> "Full Name", "phone_number" -> "Phone
|
||||
* Number"). Single-word fields stay capitalised. */
|
||||
export function humanizeFieldName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Entity type alias map for the feed labels. Most types humanise fine
|
||||
* via `humanizeFieldName`, but a few read awkwardly (notes flatten to
|
||||
* their parent, multi-word types stay multi-word). */
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
residential_client: 'Residential client',
|
||||
residential_interest: 'Residential interest',
|
||||
berth_tenancy: 'Berth tenancy',
|
||||
berth_maintenance_log: 'Berth maintenance',
|
||||
berth_recommendation: 'Berth recommendation',
|
||||
client_note: 'Client note',
|
||||
yacht_note: 'Yacht note',
|
||||
company_note: 'Company note',
|
||||
interest_note: 'Interest note',
|
||||
interest_qualification: 'Interest qualification',
|
||||
document_send: 'Document send',
|
||||
document_folder: 'Document folder',
|
||||
document_template: 'Document template',
|
||||
documentTemplate: 'Document template',
|
||||
form_template: 'Form template',
|
||||
report_template: 'Report template',
|
||||
email_account: 'Email account',
|
||||
email_message: 'Email message',
|
||||
user_email_change: 'Email change',
|
||||
custom_field_definition: 'Custom field',
|
||||
custom_field_values: 'Custom field',
|
||||
expense_export: 'Expense export',
|
||||
gdpr_export: 'GDPR export',
|
||||
qualification_criterion: 'Qualification criterion',
|
||||
website_submission: 'Website submission',
|
||||
webhook_inbound: 'Inbound webhook',
|
||||
webhook_delivery: 'Webhook delivery',
|
||||
audit_log: 'Audit log',
|
||||
portal_user: 'Portal user',
|
||||
portal_session: 'Portal session',
|
||||
portal_auth_token: 'Portal token',
|
||||
client_contact: 'Client contact',
|
||||
clientContact: 'Client contact',
|
||||
clientAddress: 'Client address',
|
||||
companyAddress: 'Company address',
|
||||
clientRelationship: 'Client relationship',
|
||||
company_membership: 'Company membership',
|
||||
crm_invite: 'CRM invite',
|
||||
queue_job: 'Queue job',
|
||||
super_admin: 'Super admin',
|
||||
};
|
||||
|
||||
export function humanizeEntityType(type: string): string {
|
||||
return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type);
|
||||
}
|
||||
|
||||
/** Map enum-typed field values to their canonical human labels. The audit
|
||||
* log stores raw enum strings; the feed should render them as the
|
||||
* human form. */
|
||||
export function normalizeEnumValue(field: string, value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const f = field.replace(/_/g, '').toLowerCase();
|
||||
if (f === 'pipelinestage' || f === 'stage') {
|
||||
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' || 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. */
|
||||
export 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);
|
||||
}
|
||||
|
||||
export interface DiffSource {
|
||||
action: string;
|
||||
fieldChanged: string | null;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
/** Build a "Field: old -> new" diff string. Returns null when there's
|
||||
* nothing useful to render. Audit logs 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, then to a key-by-key compare of two flat
|
||||
* objects in `oldValue` vs `newValue`. */
|
||||
export function buildDiffLine(item: DiffSource): string | null {
|
||||
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(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
if (item.fieldChanged) {
|
||||
const field = item.fieldChanged;
|
||||
return `${humanizeFieldName(field)}: ${shortValue(item.oldValue, field)} → ${shortValue(
|
||||
item.newValue,
|
||||
field,
|
||||
)}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { apiFetch } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { actionVerb } from '@/components/shared/activity-formatting';
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
@@ -23,21 +24,6 @@ interface AuditRow {
|
||||
actor: { id: string; email: string; name: string | null } | null;
|
||||
}
|
||||
|
||||
const ACTION_VERBS: Record<string, { past: string }> = {
|
||||
create: { past: 'created' },
|
||||
update: { past: 'updated' },
|
||||
delete: { past: 'deleted' },
|
||||
archive: { past: 'archived' },
|
||||
restore: { past: 'restored' },
|
||||
merge: { past: 'merged' },
|
||||
revert: { past: 'reverted' },
|
||||
transfer: { past: 'transferred' },
|
||||
};
|
||||
|
||||
function actionVerb(action: string): string {
|
||||
return ACTION_VERBS[action]?.past ?? action;
|
||||
}
|
||||
|
||||
function formatField(field: string | null): string | null {
|
||||
if (!field) return null;
|
||||
return field
|
||||
|
||||
@@ -422,7 +422,10 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
|
||||
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
|
||||
readSetting<'PARALLEL' | 'SEQUENTIAL' | 'TEMPLATE_DEFAULT'>(
|
||||
SETTING_KEYS.documensoSigningOrder,
|
||||
portId,
|
||||
),
|
||||
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
|
||||
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
|
||||
]);
|
||||
@@ -462,7 +465,13 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
approverLabel: approverLabel ?? 'Approver',
|
||||
developerUserId: developerUserId ?? null,
|
||||
approverUserId: approverUserId ?? null,
|
||||
signingOrder: signingOrder ?? null,
|
||||
// TEMPLATE_DEFAULT is the new tri-state default - it normalises to
|
||||
// `null` here so downstream consumers (documenso-payload, signing-
|
||||
// automation) skip passing signingOrder entirely and let the
|
||||
// template's own setting win. PARALLEL / SEQUENTIAL still force
|
||||
// the override.
|
||||
signingOrder:
|
||||
signingOrder === 'PARALLEL' || signingOrder === 'SEQUENTIAL' ? signingOrder : null,
|
||||
// Resolution chain: explicit Documenso override → port's marketing
|
||||
// site URL → null (Documenso falls back to its own default, which is
|
||||
// typically the configured APP_URL = the CRM login - not what we want
|
||||
|
||||
@@ -241,14 +241,18 @@ export const REGISTRY: SettingEntry[] = [
|
||||
section: 'documenso.behavior',
|
||||
label: 'Signing order',
|
||||
description:
|
||||
'PARALLEL = all recipients can sign at once. SEQUENTIAL = each waits for the previous (v2 only — v1 always parallel).',
|
||||
'Default flow when this port creates an envelope. Leave on "Use template default" to honour each Documenso template\'s own setting; pick PARALLEL or SEQUENTIAL to override every envelope this port creates (v2 only - v1 is always parallel).',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ value: 'PARALLEL', label: 'Parallel — all recipients sign concurrently' },
|
||||
{ value: 'SEQUENTIAL', label: 'Sequential — order matters (v2 only)' },
|
||||
{
|
||||
value: 'TEMPLATE_DEFAULT',
|
||||
label: 'Use template default (recommended)',
|
||||
},
|
||||
{ value: 'PARALLEL', label: 'Parallel - all recipients sign concurrently' },
|
||||
{ value: 'SEQUENTIAL', label: 'Sequential - order matters (v2 only)' },
|
||||
],
|
||||
scope: 'port',
|
||||
defaultValue: 'PARALLEL',
|
||||
defaultValue: 'TEMPLATE_DEFAULT',
|
||||
},
|
||||
{
|
||||
key: 'documenso_redirect_url',
|
||||
|
||||
Reference in New Issue
Block a user