diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx index 9343a296..c52859ac 100644 --- a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx @@ -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() { /> + + ); } diff --git a/src/app/api/v1/admin/documenso-webhook/health/route.ts b/src/app/api/v1/admin/documenso-webhook/health/route.ts new file mode 100644 index 00000000..a0b91682 --- /dev/null +++ b/src/app/api/v1/admin/documenso-webhook/health/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/admin/documenso-webhook/test/route.ts b/src/app/api/v1/admin/documenso-webhook/test/route.ts new file mode 100644 index 00000000..1f46e5f8 --- /dev/null +++ b/src/app/api/v1/admin/documenso-webhook/test/route.ts @@ -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); + } + }), +); diff --git a/src/components/admin/documenso/webhook-health-card.tsx b/src/components/admin/documenso/webhook-health-card.tsx new file mode 100644 index 00000000..3d9a3731 --- /dev/null +++ b/src/components/admin/documenso/webhook-health-card.tsx @@ -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 | null; + } | null; + lastFailed: { + id: string; + receivedAt: string; + metadata: Record | 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({ + queryKey: ['admin', 'documenso-webhook', 'health'], + queryFn: () => apiFetch('/api/v1/admin/documenso-webhook/health'), + staleTime: 30_000, + }); + + async function handleTest() { + setTesting(true); + try { + const res = await apiFetch('/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 ( + + + + + Webhook health + + + + {isLoading ? ( +

Loading...

+ ) : data?.data ? ( + <> +
+
+

+ Secret +

+

+ {data.data.secretConfigured ? ( + + ) : ( + + )} + {data.data.secretConfigured ? 'Configured' : 'Not set'} + {data.data.secretSource ? ( + + from {data.data.secretSource} + + ) : null} +

+
+
+

+ Expected URL +

+

{data.data.expectedUrl}

+
+
+ +
+

+ Last received +

+ {data.data.lastReceived ? ( +

+ {data.data.lastReceived.action}{' '} + + {formatDistanceToNowStrict(new Date(data.data.lastReceived.receivedAt), { + addSuffix: true, + })} + +

+ ) : ( +

+ No inbound webhook events yet. Click "Test now" below to fire one + through the receiver. +

+ )} +
+ + {data.data.lastFailed ? ( +
+

+ + Recent secret rejection +

+

+ 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. +

+
+ ) : null} + +
+ +

+ 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. +

+
+ + ) : null} +
+
+ ); +} diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx index 60a9495e..06f53870 100644 --- a/src/components/dashboard/activity-feed.tsx +++ b/src/components/dashboard/activity-feed.tsx @@ -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 = { - 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); - 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; - 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; - const newObj = item.newValue as Record; - 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 = { - create: 'default', - update: 'secondary', - delete: 'destructive', - archive: 'outline', - restore: 'secondary', -}; - function ActionBadge({ action }: { action: string }) { - const variant = ACTION_VARIANTS[action] ?? 'outline'; return ( - - {action} + + {actionVerb(action)} ); } diff --git a/src/components/shared/activity-formatting.ts b/src/components/shared/activity-formatting.ts new file mode 100644 index 00000000..16625b06 --- /dev/null +++ b/src/components/shared/activity-formatting.ts @@ -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 = { + deposit_10pct: 'deposit_paid', + contract_sent: 'contract', + contract_signed: 'contract', +}; + +const ACTION_VERBS: Record = { + 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 = { + 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); + 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; + 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; + const newObj = item.newValue as Record; + 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; +} diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx index 7acf1d0c..8761a346 100644 --- a/src/components/shared/entity-activity-feed.tsx +++ b/src/components/shared/entity-activity-feed.tsx @@ -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 = { - 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 diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 4ba4910b..041e340d 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -422,7 +422,10 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoApproverLabel, portId), readSetting(SETTING_KEYS.documensoDeveloperUserId, portId), readSetting(SETTING_KEYS.documensoApproverUserId, portId), - readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId), + readSetting<'PARALLEL' | 'SEQUENTIAL' | 'TEMPLATE_DEFAULT'>( + SETTING_KEYS.documensoSigningOrder, + portId, + ), readSetting(SETTING_KEYS.documensoRedirectUrl, portId), readSetting(SETTING_KEYS.publicSiteUrl, portId), ]); @@ -462,7 +465,13 @@ export async function getPortDocumensoConfig(portId: string): Promise