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 { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||||
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
||||||
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
|
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 { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -205,6 +206,8 @@ export default function DocumensoSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EmbeddedSigningCard />
|
<EmbeddedSigningCard />
|
||||||
|
|
||||||
|
<WebhookHealthCard />
|
||||||
</div>
|
</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 { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import {
|
import {
|
||||||
STAGE_LABELS,
|
actionVariant,
|
||||||
PIPELINE_STAGES,
|
actionVerb,
|
||||||
LEGACY_STAGE_REMAP,
|
buildDiffLine,
|
||||||
formatSource,
|
humanizeEntityType,
|
||||||
type PipelineStage,
|
} from '@/components/shared/activity-formatting';
|
||||||
} from '@/lib/constants';
|
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,189 +41,10 @@ interface ActivityItem {
|
|||||||
createdAt: string;
|
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 }) {
|
function ActionBadge({ action }: { action: string }) {
|
||||||
const variant = ACTION_VARIANTS[action] ?? 'outline';
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} className="shrink-0 capitalize text-xs">
|
<Badge variant={actionVariant(action)} className="shrink-0 capitalize text-xs">
|
||||||
{action}
|
{actionVerb(action)}
|
||||||
</Badge>
|
</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 { Button } from '@/components/ui/button';
|
||||||
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
|
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { actionVerb } from '@/components/shared/activity-formatting';
|
||||||
|
|
||||||
interface AuditRow {
|
interface AuditRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,21 +24,6 @@ interface AuditRow {
|
|||||||
actor: { id: string; email: string; name: string | null } | null;
|
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 {
|
function formatField(field: string | null): string | null {
|
||||||
if (!field) return null;
|
if (!field) return null;
|
||||||
return field
|
return field
|
||||||
|
|||||||
@@ -422,7 +422,10 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
|||||||
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
|
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
|
||||||
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
|
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
|
||||||
readSetting<string>(SETTING_KEYS.documensoApproverUserId, 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.documensoRedirectUrl, portId),
|
||||||
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
|
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
|
||||||
]);
|
]);
|
||||||
@@ -462,7 +465,13 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
|||||||
approverLabel: approverLabel ?? 'Approver',
|
approverLabel: approverLabel ?? 'Approver',
|
||||||
developerUserId: developerUserId ?? null,
|
developerUserId: developerUserId ?? null,
|
||||||
approverUserId: approverUserId ?? 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
|
// Resolution chain: explicit Documenso override → port's marketing
|
||||||
// site URL → null (Documenso falls back to its own default, which is
|
// site URL → null (Documenso falls back to its own default, which is
|
||||||
// typically the configured APP_URL = the CRM login - not what we want
|
// typically the configured APP_URL = the CRM login - not what we want
|
||||||
|
|||||||
@@ -241,14 +241,18 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
section: 'documenso.behavior',
|
section: 'documenso.behavior',
|
||||||
label: 'Signing order',
|
label: 'Signing order',
|
||||||
description:
|
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',
|
type: 'radio',
|
||||||
options: [
|
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',
|
scope: 'port',
|
||||||
defaultValue: 'PARALLEL',
|
defaultValue: 'TEMPLATE_DEFAULT',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'documenso_redirect_url',
|
key: 'documenso_redirect_url',
|
||||||
|
|||||||
Reference in New Issue
Block a user