497 lines
15 KiB
TypeScript
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));
|
|
}
|