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:
@@ -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];
|
||||
|
||||
48
src/lib/db/migrations/0071_pg_trgm_search_indexes.sql
Normal file
48
src/lib/db/migrations/0071_pg_trgm_search_indexes.sql
Normal 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);
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user