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; } 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; } 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(path: string, init?: RequestInit): Promise { 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(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise { return request(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}), }); } const post = (path: string, body?: unknown) => send('POST', path, body); const patch = (path: string, body: unknown) => send('PATCH', path, body); const del = (path: string) => request(path, { method: 'DELETE' }); const qs = (params: Record) => new URLSearchParams( Object.entries(params).filter(([, value]) => value) as [string, string][], ).toString(); export const api = { me: () => request('/api/me'), staff: () => request('/api/staff'), /** An existing name is reactivated rather than rejected. */ createStaff: (name: string) => post('/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(`/api/businesses?${qs({ status, search })}`), business: (id: string) => request(`/api/businesses/${id}`), businessFiles: (id: string) => request(`/api/businesses/${id}/files`), businessDeals: (id: string) => request(`/api/businesses/${id}/deals`), scan: () => post('/api/businesses/scan'), buyers: (status: BuyerStatus | '', search: string) => request(`/api/buyers?${qs({ status, search })}`), buyer: (id: string) => request(`/api/buyers/${id}`), updateBuyer: (id: string, body: Record) => patch(`/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('/api/inquiries', body), addContact: (buyerId: string, body: Record) => post(`/api/buyers/${buyerId}/contacts`, body), updateContact: (id: string, body: Record) => patch(`/api/contacts/${id}`, body), updateNda: (id: string, body: Record) => patch(`/api/ndas/${id}`, body), addDeal: (ndaId: string, businessId: string) => post(`/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(`/api/deals/${id}/notes`), notes: (ref: Ref, includeRelated = false) => request( `/api/notes?${qs({ ...ref, include_related: includeRelated ? 'true' : undefined })}`, ), createNote: (ref: Ref, text: string, highlight: boolean) => post('/api/notes', { ...ref, text, highlight }), updateNote: (id: string, body: { text?: string; highlight?: boolean }) => patch(`/api/notes/${id}`, body), deleteNote: (id: string) => del<{ ok: boolean }>(`/api/notes/${id}`), todos: (params: Ref & { assigned_to?: string; status?: TodoStatus }) => request(`/api/todos?${qs({ ...params })}`), createTodo: (body: Ref & Record) => post('/api/todos', body), updateTodo: (id: string, body: Record) => patch(`/api/todos/${id}`, body), todoDone: (id: string) => post(`/api/todos/${id}/done`), todoReopen: (id: string) => post(`/api/todos/${id}/reopen`), today: (staffId?: string) => request(`/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(`/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('/api/nda-inbox/import', { signature_request_id: signatureRequestId, buyer_id: buyerId, business_id: businessId, }), syncInbox: () => post<{ state: RefreshState }>('/api/nda-inbox/sync'), backfillFields: () => post('/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)); }