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:
2026-05-18 14:22:11 +02:00
parent 4b5f85cb7d
commit 0f99f054b3
21 changed files with 1399 additions and 258 deletions

View File

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