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:
@@ -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