This commit is contained in:
2026-07-26 18:19:25 -05:00
parent b0e677d298
commit 32c8ccc3ed
15 changed files with 2679 additions and 36 deletions

View File

@@ -38,6 +38,132 @@ export interface ScanResult {
missing: number;
}
// ------------------------------------------------------------ Buyer side
export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY';
export type NdaStatus = 'SENT' | 'SIGNED';
export type DealStatus = 'NEW' | 'INFO_SENT' | 'DUE_DILIGENCE' | 'LOI' | 'CLOSING' | 'ENDED';
/** Calendar day as 'YYYY-MM-DD' — the API never sends date columns as instants. */
export type Day = string;
export interface PrimaryContact {
name: string;
email: string | null;
phone: string | null;
}
export interface BuyerListItem {
id: string;
company_name: string | null;
status: BuyerStatus;
primary_contact: PrimaryContact | null;
nda_count: number;
open_deal_count: number;
}
export interface BuyerList {
buyers: BuyerListItem[];
counts: Record<BuyerStatus, number>;
}
export interface DuplicateCandidate {
buyer_id: string;
company_name: string | null;
buyer_status: BuyerStatus;
/** Any of 'email' | 'name' | 'phone' — a buyer can match on several at once. */
matched_on: string[];
contact_name: string;
contact_email: string | null;
nda_count: number;
last_date: Day | null;
}
export interface Contact {
id: string;
name: string;
email: string | null;
phone: string | null;
cell: string | null;
is_primary: boolean;
}
export interface Deal {
id: string;
status: DealStatus;
follow_up_at: Day | null;
note_count: number;
business: { id: string; name: string; status: BusinessStatus };
}
export interface NdaRound {
id: string;
status: NdaStatus;
nas_path: string | null;
sent_at: Day | null;
signed_at: Day | null;
intro_date: Day | null;
preferred_businesses_text: string | null;
total_purchase_price: string | null;
down_payment: string | null;
deals: Deal[];
}
export interface Buyer {
id: string;
company_name: string | null;
status: BuyerStatus;
address: string | null;
state: string | null;
background_experience: string | null;
how_heard: string | null;
/** null = not answered on the intake sheet, distinct from an explicit false. */
interested_in_updates: boolean | null;
contacts: Contact[];
ndas: NdaRound[];
/** Only present on the PATCH response. */
open_deal_count?: number;
}
export interface DealNote {
id: string;
text: string;
highlight: boolean;
created_at: string;
author: string | null;
}
export interface BusinessDeal {
id: string;
status: DealStatus;
buyer_id: string;
company_name: string | null;
buyer_status: BuyerStatus;
contact_name: string | null;
intro_date: Day | null;
signed_at: Day | null;
created_at: string;
}
export interface InquiryInput {
buyer_id?: string;
company_name?: string;
contact: { name: string; email?: string; phone?: string; cell?: string };
business_id: string;
backfill?: {
deal_status: DealStatus;
nda_status: NdaStatus;
signed_at?: string;
nda_nas_path?: string;
};
}
export interface InquiryResult {
buyer_id: string;
nda_id: string;
deal_id: string;
created: { buyer: boolean; contact: boolean };
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) {
super(message);
@@ -53,26 +179,60 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return (await res.json()) as T;
}
function post<T>(path: string, body?: unknown): Promise<T> {
function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
}
const post = <T>(path: string, body?: unknown) => send<T>('POST', path, body);
const patch = <T>(path: string, body: unknown) => send<T>('PATCH', path, body);
const qs = (params: Record<string, string>) =>
new URLSearchParams(params).toString();
export const api = {
me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'),
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
logout: () => post<{ ok: boolean }>('/api/logout'),
businesses: (status: BusinessStatus, search: string) =>
request<BusinessList>(
`/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`,
),
businesses: (status: BusinessStatus | '', search: string) =>
request<BusinessList>(`/api/businesses?${qs({ status, search })}`),
business: (id: string) => request<Business>(`/api/businesses/${id}`),
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
businessDeals: (id: string) => request<BusinessDeal[]>(`/api/businesses/${id}/deals`),
scan: () => post<ScanResult>('/api/businesses/scan'),
buyers: (status: BuyerStatus | '', search: string) =>
request<BuyerList>(`/api/buyers?${qs({ status, search })}`),
buyer: (id: string) => request<Buyer>(`/api/buyers/${id}`),
updateBuyer: (id: string, body: Record<string, unknown>) =>
patch<Buyer>(`/api/buyers/${id}`, body),
duplicates: (probe: { email?: string; name?: string; phone?: string }) =>
request<{ candidates: DuplicateCandidate[] }>(
`/api/buyers/duplicates?${qs({
email: probe.email ?? '',
name: probe.name ?? '',
phone: probe.phone ?? '',
})}`,
),
createInquiry: (body: InquiryInput) => post<InquiryResult>('/api/inquiries', body),
addContact: (buyerId: string, body: Record<string, unknown>) =>
post<Contact>(`/api/buyers/${buyerId}/contacts`, body),
updateContact: (id: string, body: Record<string, unknown>) =>
patch<Contact>(`/api/contacts/${id}`, body),
updateNda: (id: string, body: Record<string, unknown>) =>
patch<NdaRound>(`/api/ndas/${id}`, body),
addDeal: (ndaId: string, businessId: string) =>
post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }),
setDealStatus: (id: string, status: DealStatus, comment?: string) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${id}/status`,
{ status, comment },
),
dealNotes: (id: string) => request<DealNote[]>(`/api/deals/${id}/notes`),
};
/** Same-origin streaming URL of one file inside a business directory. */