Files
bizmatch-app/web/src/api.ts
2026-07-29 14:50:11 -05:00

497 lines
15 KiB
TypeScript

export interface Staff {
id: string;
name: string;
active: boolean;
}
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
export interface BusinessListItem {
id: string;
name: string;
status: BusinessStatus;
}
export interface BusinessList {
businesses: BusinessListItem[];
counts: Record<BusinessStatus, number>;
}
export interface Business {
id: string;
name: string;
nas_path: string;
status: BusinessStatus;
}
export interface BusinessFile {
/** Relative to the business directory, posix separators. */
path: string;
size: number;
mtime: string;
}
export interface ScanResult {
scanned: number;
inserted: number;
updated: number;
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;
todo_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;
/** Set when the round came in through the NDA inbox. */
dropbox_sign_id: string | null;
signer_name: string | null;
/** A declined round stays SENT — it was never signed. */
declined: boolean;
/** Read straight off the signed Dropbox Sign form. */
income_requirements: string | null;
accountant: string | null;
attorney: string | null;
bank: 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 };
}
// ------------------------------------------------- Notes / todos / Today
export type TodoKind = 'TASK' | 'REVIEW';
export type TodoStatus = 'OPEN' | 'DONE';
export interface Author {
id: string;
name: string;
}
/** What a note or todo hangs off, with the ids needed to link to it. */
export interface RefContext {
type: 'buyer' | 'deal' | 'business' | 'nda';
id: string;
label: string;
buyer_id: string | null;
business_id: string | null;
}
/** Exactly one of these for a note, at most one for a todo. */
export interface Ref {
buyer_id?: string;
deal_id?: string;
business_id?: string;
nda_id?: string;
}
export interface Note {
id: string;
text: string;
highlight: boolean;
created_at: string;
author: Author | null;
context: RefContext | null;
}
export interface Todo {
id: string;
text: string;
kind: TodoKind;
status: TodoStatus;
due_at: Day | null;
document_id: string | null;
document_name: string | null;
done_at: string | null;
done_by_name: string | null;
assigned_to: Author;
author: Author | null;
context: RefContext | null;
}
export interface FollowUp {
id: string;
status: DealStatus;
follow_up_at: Day;
buyer_id: string;
buyer_name: string;
business_id: string;
business_name: string;
created_by_id: string | null;
created_by_name: string | null;
}
export interface PendingNda {
id: string;
sent_at: Day;
buyer_id: string;
buyer_name: string;
created_by_id: string | null;
created_by_name: string | null;
}
export interface TodayCounts {
overdue_todos: number;
due_todos: number;
follow_ups: number;
pending_ndas: number;
total: number;
}
export interface Today {
todos: (Todo & { overdue: boolean })[];
follow_ups: FollowUp[];
pending_ndas: PendingNda[];
counts: TodayCounts;
}
// ------------------------------------------------------------- NDA inbox
export type InboxStatus = 'pending' | 'signed' | 'declined';
export interface InboxRow {
signature_request_id: string;
title: string;
/** The title with the prefix and the signer's name stripped out. */
title_remainder: string;
/** Full ISO timestamp: Dropbox orders by date *and* time. */
created_at: string;
status: InboxStatus;
signer: { name: string; email: string };
/** Full ISO timestamp as well — the second the signature came in. */
signed_at: string | null;
/** signed_at once signed, created_at until then: the moment that matters. */
relevant_at: string;
imported: { nda_id: string; buyer_id: string } | null;
known_buyer: { buyer_id: string; company_name: string | null; contact_name: string } | null;
business_suggestions: { id: string; name: string; status: BusinessStatus }[];
}
export type RefreshState = 'idle' | 'running' | string;
export interface RefreshResult {
pages: number;
seen: number;
stored: number;
/** Of `stored`, how many used the pre-rename title format. */
legacy_stored: number;
}
export interface InboxCounts {
pending: number;
signed: number;
declined: number;
all: number;
}
export interface Inbox {
requests: InboxRow[];
/** Per status over the whole matching set, not just the rows shipped. */
counts: InboxCounts;
/** ISO timestamp of the last completed background refresh, null if never. */
last_refresh_at: string | null;
refresh_state: RefreshState;
/** What the last completed walk did, null before the first one. */
last_refresh_result: RefreshResult | null;
/** The sync runs in the background too, with the same state pattern. */
last_sync_at: string | null;
sync_state: RefreshState;
last_sync_result: SyncResult | null;
/** ISO date the mirror actually reaches back to, null if never walked. */
covers_from: string | null;
}
export interface ImportResult {
buyer_id: string;
nda_id: string;
deal_id: string | null;
created: { buyer: boolean; contact: boolean };
status: InboxStatus;
nas_path: string | null;
/** Set when the round was imported but the PDF could not be filed. */
warning: string | null;
}
export interface BackfillResult {
candidates: number;
filled: number;
empty: number;
failed: number;
warnings: string[];
}
export interface SyncResult {
checked: number;
signed: number;
declined: number;
failed: number;
/** Old pending mirror rows re-fetched one by one, and how many had moved on. */
mirror_candidates: number;
mirror_rechecked: number;
mirror_changed: number;
mirror_failed: number;
warnings: string[];
warnings_omitted: number;
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: 'same-origin', ...init });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`);
}
return (await res.json()) as T;
}
function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
return request<T>(path, {
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 del = <T>(path: string) => request<T>(path, { method: 'DELETE' });
const qs = (params: Record<string, string | undefined>) =>
new URLSearchParams(
Object.entries(params).filter(([, value]) => value) as [string, string][],
).toString();
export const api = {
me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'),
/** An existing name is reactivated rather than rejected. */
createStaff: (name: string) => post<Staff>('/api/staff', { name }),
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?${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 }),
deleteDeal: (id: string) => del<{ ok: boolean }>(`/api/deals/${id}`),
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`),
notes: (ref: Ref, includeRelated = false) =>
request<Note[]>(
`/api/notes?${qs({ ...ref, include_related: includeRelated ? 'true' : undefined })}`,
),
createNote: (ref: Ref, text: string, highlight: boolean) =>
post<Note>('/api/notes', { ...ref, text, highlight }),
updateNote: (id: string, body: { text?: string; highlight?: boolean }) =>
patch<Note>(`/api/notes/${id}`, body),
deleteNote: (id: string) => del<{ ok: boolean }>(`/api/notes/${id}`),
todos: (params: Ref & { assigned_to?: string; status?: TodoStatus }) =>
request<Todo[]>(`/api/todos?${qs({ ...params })}`),
createTodo: (body: Ref & Record<string, unknown>) => post<Todo>('/api/todos', body),
updateTodo: (id: string, body: Record<string, unknown>) => patch<Todo>(`/api/todos/${id}`, body),
todoDone: (id: string) => post<Todo>(`/api/todos/${id}/done`),
todoReopen: (id: string) => post<Todo>(`/api/todos/${id}/reopen`),
today: (staffId?: string) => request<Today>(`/api/today?${qs({ staff_id: staffId })}`),
followUpSent: (dealId: string, comment: string, rearm: boolean) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${dealId}/follow-up-sent`,
{ comment, rearm },
),
/** Pins a business file so a REVIEW todo can point at it. */
createDocument: (businessId: string, filePath: string) =>
post<{ id: string; nas_path: string }>('/api/documents', {
business_id: businessId,
path: filePath,
}),
/** Reads the mirrored requests out of the database — never calls Dropbox. */
ndaInbox: (since: string, status?: InboxStatus | '', q?: string) =>
request<Inbox>(`/api/nda-inbox?${qs({ since, status, q })}`),
/**
* Starts the background walk; resolves as soon as it is queued. Passing
* `since` makes the server re-read that whole window — leave it out for the
* incremental catch-up, which is what a plain Refresh should do.
*/
refreshInbox: (since?: string) =>
post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`),
importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) =>
post<ImportResult>('/api/nda-inbox/import', {
signature_request_id: signatureRequestId,
buyer_id: buyerId,
business_id: businessId,
}),
syncInbox: () => post<{ state: RefreshState }>('/api/nda-inbox/sync'),
backfillFields: () => post<BackfillResult>('/api/nda-inbox/backfill-fields'),
};
/** Same-origin streaming URL of the PDF filed for one NDA round. */
export function ndaFileUrl(ndaId: string): string {
return `/api/ndas/${ndaId}/file`;
}
/** Same-origin streaming URL of one file inside a business directory. */
export function businessFileUrl(id: string, filePath: string): string {
return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`;
}
/** The standalone (unbundled) pdf.js viewer page, pointed at any /api/ file URL. */
export function viewerUrlFor(apiPath: string): string {
return `/viewer/index.html?file=${encodeURIComponent(apiPath)}`;
}
/** The viewer, pointed at a business file. */
export function viewerUrl(id: string, filePath: string): string {
return viewerUrlFor(businessFileUrl(id, filePath));
}