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

@@ -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>
);
}

View 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);
}
}),
);

View 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);
}
}),
);

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>
);
}

View File

@@ -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>
);
}

View 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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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',