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

@@ -393,6 +393,11 @@ export const DOCUMENT_STATUSES = [
'completed',
'expired',
'cancelled',
// Documenso writes both 'rejected' and 'declined' depending on which
// webhook path fires; we mirror that on the document row. Surface
// both so DocumentStatus checks against either spelling type-check.
'rejected',
'declined',
] as const;
export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number];

View File

@@ -0,0 +1,48 @@
-- M-P01: pg_trgm GIN indexes on the leading-wildcard ILIKE search
-- columns used by `buildListQuery` (src/lib/db/query-builder.ts:67).
--
-- Without these, every `ILIKE '%term%'` predicate sequential-scans
-- the entire table. The fix isn't to rewrite the SQL — Postgres will
-- transparently use a `gin_trgm_ops` index for ILIKE patterns once
-- one exists on the column.
--
-- The pg_trgm extension is a one-time install; CREATE EXTENSION IF NOT
-- EXISTS is idempotent. Each CREATE INDEX CONCURRENTLY runs outside
-- a transaction (the db-migrate runner detects CONCURRENTLY and
-- splits the statement out of the wrapping tx).
CREATE EXTENSION IF NOT EXISTS pg_trgm;
--> statement-breakpoint
-- ─── Clients ──────────────────────────────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_clients_full_name_trgm
ON clients USING gin (full_name gin_trgm_ops);
--> statement-breakpoint
-- ─── Yachts ──────────────────────────────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_yachts_name_trgm
ON yachts USING gin (name gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_yachts_builder_trgm
ON yachts USING gin (builder gin_trgm_ops);
--> statement-breakpoint
-- ─── Companies ───────────────────────────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_companies_name_trgm
ON companies USING gin (name gin_trgm_ops);
--> statement-breakpoint
-- ─── Berths ──────────────────────────────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_berths_mooring_number_trgm
ON berths USING gin (mooring_number gin_trgm_ops);
--> statement-breakpoint
-- ─── Residential clients (parallel client surface) ───────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_residential_clients_full_name_trgm
ON residential_clients USING gin (full_name gin_trgm_ops);
--> statement-breakpoint
-- ─── Tags (filter-bar autocomplete) ──────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tags_name_trgm
ON tags USING gin (name gin_trgm_ops);

View File

@@ -19,7 +19,12 @@ export type DocumentStatus =
| 'partially_signed'
| 'completed'
| 'expired'
| 'cancelled';
| 'cancelled'
// §4.13: Documenso writes 'rejected' (DOCUMENT_REJECTED webhook) and
// some legacy paths use 'declined' for the same outcome. Surface both
// so the rejection-banner check in interest-eoi-tab type-checks.
| 'rejected'
| 'declined';
/**
* Human label rendered in CRM UI (staff-facing). Use the portal-specific
@@ -34,6 +39,8 @@ export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
rejected: 'Declined',
declined: 'Declined',
};
/**
@@ -48,6 +55,8 @@ export const DOCUMENT_STATUS_LABELS_PORTAL: Record<DocumentStatus, string> = {
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
rejected: 'Declined',
declined: 'Declined',
};
/**
@@ -62,6 +71,8 @@ export const DOCUMENT_STATUS_PILL: Record<DocumentStatus, StatusPillStatus> = {
completed: 'completed',
expired: 'expired',
cancelled: 'cancelled',
rejected: 'rejected',
declined: 'declined',
};
/**

View File

@@ -1891,6 +1891,56 @@ export async function handleDocumentRejected(eventData: {
documentId: doc.id,
signerEmail: eventData.recipientEmail ?? null,
});
// §4.13: rejection cascade. When any signer declines:
// 1. Notify the interest's assigned rep in-CRM (drives the EOI tab
// banner via the realtime invalidation + the bell).
// 2. Audit-log so the timeline surfaces the rejection.
// Email cascade to the other signers is intentionally NOT fired —
// the legal flow is "this EOI is dead, regenerate"; messaging the
// co-signers would create noise. The rep handles outreach manually.
if (doc.interestId) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)),
columns: { assignedTo: true, clientId: true },
});
const targetUserId = interest?.assignedTo ?? null;
if (targetUserId) {
const { createNotification } = await import('@/lib/services/notifications.service');
void createNotification({
portId: doc.portId,
userId: targetUserId,
type: 'document_rejected',
title: 'EOI declined',
description: eventData.recipientEmail
? `${eventData.recipientEmail} declined to sign — review and regenerate.`
: 'A signer declined the EOI — review and regenerate.',
link: `/interests/${doc.interestId}?tab=eoi`,
entityType: 'document',
entityId: doc.id,
dedupeKey: `document:${doc.id}:rejected`,
}).catch(() => {
// Notification failure shouldn't block the webhook handler.
});
}
}
// Audit verb so the rep's timeline surfaces the rejection with a
// distinct icon/copy rather than a generic document_event row.
const { createAuditLog } = await import('@/lib/audit');
void createAuditLog({
userId: 'system',
portId: doc.portId,
action: 'update',
entityType: 'document',
entityId: doc.id,
metadata: {
type: 'document_declined',
signerEmail: eventData.recipientEmail ?? null,
},
ipAddress: '',
userAgent: '',
});
}
export async function handleDocumentCancelled(eventData: {
@@ -1934,11 +1984,25 @@ export interface DocumentDetailWatcher {
addedAt: Date;
}
/**
* #67 linked-entity resolution: resolve each polymorphic FK on the
* document to a human-readable name so the doc-detail "Linked entity"
* card can render "Interest — Matt Ciaccio" instead of "Interest →".
* Each side is null when the FK is null or the row was deleted.
*/
export interface DocumentDetailLinkedEntities {
interest: { id: string; clientName: string | null } | null;
client: { id: string; fullName: string } | null;
yacht: { id: string; name: string } | null;
company: { id: string; name: string } | null;
}
export interface DocumentDetail {
document: typeof documents.$inferSelect;
signers: (typeof documentSigners.$inferSelect)[];
events: (typeof documentEvents.$inferSelect)[];
watchers: DocumentDetailWatcher[];
linked: DocumentDetailLinkedEntities;
}
/**
@@ -1968,7 +2032,53 @@ export async function getDocumentDetail(id: string, portId: string): Promise<Doc
.where(eq(documentWatchers.documentId, id)),
]);
return { document, signers, events, watchers };
// #67: resolve linked-entity names. Each helper does its own port
// check via the parent FK. Skipped when the FK is null. All four
// are parallel since they hit different tables.
const [interestRow, clientRow, yachtRow, companyRow] = await Promise.all([
document.interestId
? db
.select({
id: interests.id,
clientId: interests.clientId,
clientName: clients.fullName,
})
.from(interests)
.leftJoin(clients, eq(clients.id, interests.clientId))
.where(and(eq(interests.id, document.interestId), eq(interests.portId, portId)))
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
document.clientId
? db.query.clients.findFirst({
where: and(eq(clients.id, document.clientId), eq(clients.portId, portId)),
columns: { id: true, fullName: true },
})
: Promise.resolve(undefined),
document.yachtId
? db.query.yachts.findFirst({
where: and(eq(yachts.id, document.yachtId), eq(yachts.portId, portId)),
columns: { id: true, name: true },
})
: Promise.resolve(undefined),
document.companyId
? db.query.companies.findFirst({
where: and(eq(companies.id, document.companyId), eq(companies.portId, portId)),
columns: { id: true, name: true },
})
: Promise.resolve(undefined),
]);
const linked: DocumentDetailLinkedEntities = {
interest: interestRow
? { id: interestRow.id, clientName: interestRow.clientName ?? null }
: null,
client: clientRow ? { id: clientRow.id, fullName: clientRow.fullName } : null,
yacht: yachtRow ? { id: yachtRow.id, name: yachtRow.name } : null,
company: companyRow ? { id: companyRow.id, name: companyRow.name } : null,
};
return { document, signers, events, watchers, linked };
}
/**

View File

@@ -1,68 +0,0 @@
import OpenAI from 'openai';
import { logger } from '@/lib/logger';
import { env } from '@/lib/env';
// M-IN02: lazy-instantiate so a missing/invalid OPENAI_API_KEY doesn't
// fail boot — the receipt-scan path is opt-in and only some ports
// will have OCR configured. Cached after first construction so we
// don't pay the cost on every scan.
let openaiClient: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!openaiClient) {
if (!env.OPENAI_API_KEY) {
throw new Error(
'OPENAI_API_KEY is not configured — receipt OCR is unavailable. Set the key in /admin/ai or .env.',
);
}
openaiClient = new OpenAI({ apiKey: env.OPENAI_API_KEY });
}
return openaiClient;
}
interface ScanResult {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
export async function scanReceipt(imageBuffer: Buffer, mimeType: string): Promise<ScanResult> {
try {
const base64 = imageBuffer.toString('base64');
const response = await getOpenAI().chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Extract receipt data as JSON: { establishment, date (ISO), amount (number), currency (3-letter code), lineItems: [{ description, amount }], confidence (0-1) }. Return ONLY valid JSON.',
},
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
],
},
],
max_tokens: 1000,
});
const content = response.choices[0]?.message?.content ?? '{}';
const cleaned = content.replace(/```json\n?|\n?```/g, '').trim();
return JSON.parse(cleaned) as ScanResult;
} catch (err) {
logger.error({ err }, 'Receipt scan failed');
return {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
};
}
}