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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user