feat(post-audit): batch A+B quick-wins + audit-side residuals
Bundles the user-prioritised follow-ups from the post-audit punch-list.
Batch A — pipeline + EOI safety:
- §1.1 timeline buildAuditDescription renders diff fields ("leadCategory → hot_lead").
- §4.13 EOI rejection cascade: notification to assigned rep + audit row + rose banner.
- §4.10b finish doc-detail: SigningProgress reuse, linked-entity names (server-resolved),
per-event icons + tooltips + show-more in activity panel.
- §7.2 stage guidance card replaces empty Payments slot pre-reservation.
- §4.15 deal-pulse trigger audit (docs/deal-pulse-trigger-audit.md).
Batch B — UX consistency + docs:
- §1.4 quick log-contact button on interest header.
- §2.1 contact-log compose: Dialog → Sheet.
- §7.1 docs/deal-pulse explainer page; /docs/ in PUBLIC_PATHS.
- DocumentStatus now includes 'rejected' + 'declined' across constants, labels, tone maps.
Audit-side residuals:
- M-NEW-1 /me/ports skips port-context requirement.
- M-AU03 audit log CSV export endpoint + UI button.
- M-IN03 dead receipt-scanner.ts deleted; live path already per-port.
- M-P01 pg_trgm GIN indexes (migration 0071).
- §10.1 webhook tests verified passing (was stale).
Deferred per user direction:
- §11.3 email copy refactor (needs old-CRM reference).
- M-EM03 IMAP bounce-to-interest linking.
Tests: 1374/1374. tsc + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,77 @@ function buildAuditDescription(
|
||||
if (action === 'update' && newValue?.pipelineStage) {
|
||||
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
|
||||
}
|
||||
if (action === 'update') return 'Interest updated';
|
||||
if (action === 'update') {
|
||||
// §1.1: surface which field(s) changed instead of a generic
|
||||
// "Interest updated". We have the new-value bag in audit_logs;
|
||||
// human-friendly labels for the most common fields.
|
||||
return describeUpdateDiff(newValue);
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a "leadCategory: hot_lead, source: website" style description from
|
||||
* an audit log's newValue payload. Filters out audit-internal fields,
|
||||
* passes through human-friendly labels for known fields, falls back to
|
||||
* the raw key name when the field isn't in the catalog.
|
||||
*/
|
||||
function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
|
||||
if (!newValue) return 'Interest updated';
|
||||
|
||||
// Audit-internal / housekeeping fields skipped from the timeline copy.
|
||||
const SKIP = new Set(['updatedAt', 'createdAt', 'id', 'portId']);
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
leadCategory: 'lead category',
|
||||
source: 'source',
|
||||
assignedTo: 'owner',
|
||||
yachtId: 'yacht',
|
||||
berthId: 'primary berth',
|
||||
eoiDocStatus: 'EOI status',
|
||||
reservationDocStatus: 'reservation status',
|
||||
contractDocStatus: 'contract status',
|
||||
dateEoiSent: 'EOI sent date',
|
||||
dateEoiSigned: 'EOI signed date',
|
||||
dateReservationSigned: 'reservation signed date',
|
||||
dateContractSent: 'contract sent date',
|
||||
dateContractSigned: 'contract signed date',
|
||||
depositExpectedAmount: 'expected deposit',
|
||||
depositExpectedCurrency: 'deposit currency',
|
||||
desiredLengthFt: 'desired length',
|
||||
desiredWidthFt: 'desired width',
|
||||
desiredDraftFt: 'desired draft',
|
||||
desiredLengthM: 'desired length (m)',
|
||||
desiredWidthM: 'desired width (m)',
|
||||
desiredDraftM: 'desired draft (m)',
|
||||
reminderEnabled: 'follow-up reminder',
|
||||
reminderDays: 'reminder cadence',
|
||||
reminderNote: 'reminder note',
|
||||
outcome: 'outcome',
|
||||
};
|
||||
|
||||
const changed: string[] = [];
|
||||
for (const [key, value] of Object.entries(newValue)) {
|
||||
if (SKIP.has(key)) continue;
|
||||
if (key === 'pipelineStage') continue; // handled by the earlier branch
|
||||
const label = FIELD_LABELS[key] ?? key;
|
||||
const formatted = formatDiffValue(value);
|
||||
changed.push(formatted ? `${label} → ${formatted}` : label);
|
||||
}
|
||||
|
||||
if (changed.length === 0) return 'Interest updated';
|
||||
if (changed.length === 1) return `Updated ${changed[0]}`;
|
||||
if (changed.length <= 3) return `Updated ${changed.join(', ')}`;
|
||||
return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`;
|
||||
}
|
||||
|
||||
function formatDiffValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return 'cleared';
|
||||
if (typeof v === 'boolean') return v ? 'on' : 'off';
|
||||
if (typeof v === 'number') return String(v);
|
||||
if (typeof v === 'string') {
|
||||
// Truncate verbose strings so the timeline line stays one row.
|
||||
return v.length > 40 ? `${v.slice(0, 37)}…` : v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user