diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9d47561..32cd5e1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,12 @@ "Bash(kill 29669 29703 29714)", "Bash(curl -s -m 2 -o /dev/null -w 'server: %{http_code}\\\\n' localhost:8090/api/health)", "Bash(git check-ignore *)", - "Bash(git add *)" + "Bash(git add *)", + "Bash(curl -s -m 3 localhost:8090/api/health)", + "Bash(bash acceptance.sh)", + "Bash(pkill -f \"tsx src/server.ts\")", + "Bash(curl -s localhost:8090/api/health)", + "Bash(curl -s -m 3 localhost:8091/api/health)" ] } } diff --git a/README.md b/README.md index 640ddb4..5d239fb 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login). Module 2: business scan (NAS -> DB) and the first UI. Module 3: recursive file listing, PDF streaming from the NAS and the ported pdf.js viewer. +Module 4: the buyer side — buyers, contacts, NDA rounds, deals, the guided +"New inquiry" flow with duplicate detection and the deal status transitions. The UI and all domain constants are English. @@ -26,6 +28,28 @@ The app applies all migrations on start and then listens on > `docker compose down -v && docker compose up -d --build`. The DB held no > production data yet, so there is nothing to migrate. +### Schema notes + +`001_init.sql` holds the full base schema (Buyer / Contact / NDA / Deal / +Business / Note / Todo / Document / ExtractionJob / Staff) and is never edited +again. `002_buyer_fields.sql` adds the fields the buyer side actually collects +and is purely additive, so it applies to an existing database: + +| Table | Added | +| --------- | ------------------------------------------------------------------------------------ | +| `contact` | `cell` | +| `buyer` | `address`, `state`, `background_experience`, `how_heard`, `interested_in_updates` | +| `nda` | `total_purchase_price`, `down_payment`, `intro_date` | + +`003_interested_in_updates_nullable.sql` then drops the `NOT NULL` and the +default from `buyer.interested_in_updates`: the value comes off scanned intake +sheets where the field is frequently blank, so `NULL` means "not answered" and +has to stay distinct from `false` ("explicitly no"). The buyer detail shows it +as a Yes / No / not answered control, and `PATCH /api/buyers/:id` accepts all +three. The two price fields stay `text` on purpose: the paper forms contain entries like "1.2M + inventory" that no +numeric type survives. The migration also adds the three index expressions the +duplicate check needs (`lower(btrim(name))` and the digits-only phone/cell). + First smoke test: ```bash @@ -85,7 +109,7 @@ directory aborts the scan with an error naming the path. 3. Copy the project folder to the AI machine, run `docker compose up -d --build` 4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql` -## API (as of module 3) +## API (as of module 4) | Method | Path | Purpose | Session | | ------ | --------------------------- | ------------------------------------------------ | ------- | @@ -100,10 +124,58 @@ directory aborts the scan with an error naming the path. | GET | /api/businesses/:id | single business incl. `nas_path` | yes | | GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes | | GET | /api/businesses/:id/file | stream one file, `?path=` | yes | +| GET | /api/businesses/:id/deals | buyer activity on one business, newest first | yes | +| GET | /api/buyers | list `?search=&status=` + counts per buyer status | yes | +| GET | /api/buyers/duplicates | candidates for `?email=&name=&phone=` | yes | +| POST | /api/inquiries | guided new-inquiry flow (one transaction) | yes | +| GET | /api/buyers/:id | buyer incl. `contacts[]` and `ndas[].deals[]` | yes | +| PATCH | /api/buyers/:id | identity fields + status (+ `end_open_deals`) | yes | +| POST | /api/buyers/:id/contacts | add a contact | yes | +| PATCH | /api/contacts/:id | edit a contact (incl. `is_primary`) | yes | +| PATCH | /api/ndas/:id | edit one NDA round | yes | +| POST | /api/ndas/:id/deals | add a business to an existing round | yes | +| POST | /api/deals/:id/status | `{status, comment?}` — transition + note | yes | +| GET | /api/deals/:id/notes | notes of one deal, newest first | yes | Everything except health, staff (GET+POST) and login requires the session cookie; without it the API answers `401`. +### Buyer side (module 4) + +Domain rules, all enforced in the API: + +* **buyer** is the buying party, **contact** are its 1..n people, **nda** is one + inquiry round (a returning buyer signs a *new* NDA), **deal** is + buyer↔business inside one round. There is deliberately no uniqueness on + `(buyer_id, business_id)` — a returning buyer gets a new round with new deals + and the history stays visible. +* Deal flow `NEW → INFO_SENT → DUE_DILIGENCE → LOI → CLOSING`, `ENDED` from + anywhere. `POST /api/deals/:id/status` rejects only a no-op (`409`); + everything else is allowed on purpose, because corrections have to be + possible. Entering `INFO_SENT` sets `follow_up_at = today + 14`, entering + `ENDED` clears it. +* When an NDA becomes `SIGNED` it gets a `signed_at` (default today) and its + buyer is set back to `ACTIVE`. +* Deactivating a buyer with `{"status":"DEACTIVATED","end_open_deals":true}` + ends all their non-`ENDED` deals; the response always carries + `open_deal_count` so the UI can warn first. + +`GET /api/buyers/duplicates` matches exactly, never fuzzily: normalised e-mail +(`lower(btrim(…))`), case-insensitive contact name, and phone **or** cell +compared digits-only, so `(361) 555-0101` and `3615550101` are the same number. +Numbers with fewer than 7 digits are ignored. A candidate reports every reason +it matched in `matched_on`. + +`POST /api/inquiries` is the guided flow and runs in one transaction. Without +`buyer_id` it creates buyer + primary contact; with `buyer_id` it reuses the +buyer and only adds the contact when no existing contact of that buyer has the +same normalised e-mail or the same name. It then creates the NDA round and one +deal, and returns +`{buyer_id, nda_id, deal_id, created:{buyer, contact}}`. The optional +`backfill` block (`deal_status`, `nda_status`, `signed_at`, `nda_nas_path`) +files a paper record in its real state — a backfilled `INFO_SENT` still arms +the 14-day follow-up, later statuses do not. + ### File listing and streaming `/files` walks the business directory recursively (max depth 3), skipping @@ -127,9 +199,27 @@ alphabetical. ## Frontend `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state -library). Views: login ("Who is working?"), business list (tabs with counts, -search, "Scan NAS now") and business detail — a master-detail split filling the -viewport: file table left, PDF viewer right. +library). The header carries the two nav entries **Businesses** and **Buyers**; +routing is a hand-rolled `{view, id}` state in `App.tsx`. Views: + +* login ("Who is working?") +* business list (tabs with counts, search, "Scan NAS now") +* business detail — a master-detail split filling the viewport: file table + left, PDF viewer right, plus a collapsed "Buyer activity" panel linking to + the buyers who were introduced to this business +* buyer list (status chips with counts, search over company/contact/e-mail, + "New inquiry") +* new inquiry — contact + business picker; while typing a known name, e-mail or + phone a warning panel lists the duplicate candidates with "Use this buyer" + (locks the buyer, shown as a chip with an undo) or "Create new buyer anyway". + The collapsible "Backfill existing deal (paper records)" section files + historic deals in their real state. +* buyer detail — status header with Deactivate/Reactivate (warns about the open + deals it would end), inline-editable identity panel, contacts with a primary + star, and the NDA rounds newest first: editable round fields, the deals of + the round with an action menu (next step, "End deal", plus a "Correct to…" + section) that opens a comment dialog, and a collapsed read-only notes list + per deal. In dev, Vite proxies `/api` to `http://localhost:8090`. In production the Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to @@ -169,19 +259,25 @@ pdf.js fails the decoders silently and shows blank white canvases. ## Structure ``` -migrations/ numbered SQL migrations (001_init.sql = full schema) +migrations/ numbered SQL migrations + 001_init.sql full schema + 002_buyer_fields.sql buyer-side fields from the NDA form + intake sheet + 003_…_nullable.sql interested_in_updates becomes tri-state src/ config.ts env configuration - db.ts pg pool + query helpers + db.ts pg pool, query helpers, withTransaction + session.ts the staff-id cookie migrate.ts migration runner (transactional, advisory lock) business-scan.ts NAS scan, recursive listing, safe file path resolution server.ts Fastify app (health, staff, login, businesses, file, static) + buyer-routes.ts buyers, contacts, NDA rounds, deals, the inquiry flow web/ scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild) public/viewer/ standalone, unbundled pdf.js viewer page public/pdfjs/ generated, git-ignored pdf.js runtime src/api.ts typed API client - src/App.tsx session gate + view switch - src/views/ Login, Businesses, BusinessDetail + src/App.tsx session gate + nav + view switch + src/components.tsx shared bits (badges, inline fields, business picker, dialog) + src/views/ Login, Businesses, BusinessDetail, Buyers, BuyerDetail, NewInquiry viewer-phase1/ reference copy of the phase-1 desktop viewer ``` diff --git a/migrations/002_buyer_fields.sql b/migrations/002_buyer_fields.sql new file mode 100644 index 0000000..d8ca17c --- /dev/null +++ b/migrations/002_buyer_fields.sql @@ -0,0 +1,30 @@ +-- BizMatch Phase 2 — module 4: the fields the buyer side actually collects +-- (NDA form + intake sheet). 001_init.sql stays untouched. + +-- -------------------------------------------------------------- contact +ALTER TABLE contact ADD COLUMN cell text; + +-- ---------------------------------------------------------------- buyer +ALTER TABLE buyer + ADD COLUMN address text, + ADD COLUMN state text, + ADD COLUMN background_experience text, + ADD COLUMN how_heard text, + ADD COLUMN interested_in_updates boolean NOT NULL DEFAULT false; + +-- ------------------------------------------------------------------ nda +-- Money stays text on purpose: the paper forms contain things like +-- "1.2M + inventory" or "TBD" that no numeric type survives. +ALTER TABLE nda + ADD COLUMN total_purchase_price text, + ADD COLUMN down_payment text, + ADD COLUMN intro_date date; + +-- ------------------------------------------- dedup anchors (duplicates) +-- The duplicate check compares names case-insensitively and phone numbers +-- digits-only, so the indexes have to match those expressions exactly. +CREATE INDEX contact_name_lower_idx ON contact (lower(btrim(name))); +CREATE INDEX contact_phone_digits_idx + ON contact ((regexp_replace(phone, '[^0-9]', '', 'g'))) WHERE phone IS NOT NULL; +CREATE INDEX contact_cell_digits_idx + ON contact ((regexp_replace(cell, '[^0-9]', '', 'g'))) WHERE cell IS NOT NULL; diff --git a/migrations/003_interested_in_updates_nullable.sql b/migrations/003_interested_in_updates_nullable.sql new file mode 100644 index 0000000..6953671 --- /dev/null +++ b/migrations/003_interested_in_updates_nullable.sql @@ -0,0 +1,8 @@ +-- BizMatch Phase 2 — module 4 follow-up +-- The value comes from scanned intake sheets where the field is frequently +-- left blank. NULL therefore has to mean "not answered" and stay distinct +-- from false, which means "explicitly no". + +ALTER TABLE buyer + ALTER COLUMN interested_in_updates DROP NOT NULL, + ALTER COLUMN interested_in_updates DROP DEFAULT; diff --git a/src/buyer-routes.ts b/src/buyer-routes.ts new file mode 100644 index 0000000..ed6bcf4 --- /dev/null +++ b/src/buyer-routes.ts @@ -0,0 +1,797 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { query, queryOne, withTransaction, type Tx } from './db.js'; +import { staffIdFromRequest } from './session.js'; + +export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY'; +export type NdaStatus = 'SENT' | 'SIGNED'; +export type DealStatus = 'NEW' | 'INFO_SENT' | 'DUE_DILIGENCE' | 'LOI' | 'CLOSING' | 'ENDED'; + +const BUYER_STATUSES = ['ACTIVE', 'DEACTIVATED', 'LEGACY'] as const; +const NDA_STATUSES = ['SENT', 'SIGNED'] as const; +const DEAL_STATUSES = [ + 'NEW', + 'INFO_SENT', + 'DUE_DILIGENCE', + 'LOI', + 'CLOSING', + 'ENDED', +] as const; + +/** Entering INFO_SENT arms the reminder; nothing else touches follow_up_at. */ +const FOLLOW_UP_DAYS = 14; + +/** Below this many digits a phone number is not distinctive enough to match on. */ +const MIN_PHONE_DIGITS = 7; + +// ----------------------------------------------------------------- Input +/** Thrown by the coercers below; the routes turn it into a 400. */ +class InputError extends Error {} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const trimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); + +/** Digits only — the duplicate check ignores every kind of phone formatting. */ +const digits = (value: string): string => value.replace(/\D/g, ''); + +/** Empty strings become NULL, so "cleared in the UI" and "never filled in" agree. */ +const asText = (value: unknown): string | null => trimmed(value) || null; + +const asRequiredText = (value: unknown): string => { + const text = trimmed(value); + if (!text) throw new InputError('value must not be empty'); + return text; +}; + +const asBool = (value: unknown): boolean => value === true; + +/** + * Tri-state: null is "not answered", which the scanned intake sheets leave + * blank often enough that it must stay distinct from an explicit false. + */ +const asNullableBool = (value: unknown): boolean | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value !== 'boolean') { + throw new InputError(`invalid value: ${String(value)} (expected true, false or null)`); + } + return value; +}; + +const asDate = (value: unknown): string | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value !== 'string' || !DATE_RE.test(value)) { + throw new InputError(`invalid date: ${String(value)} (expected YYYY-MM-DD)`); + } + return value; +}; + +const asOneOf = + (allowed: readonly T[]) => + (value: unknown): T => { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new InputError(`invalid value: ${String(value)}`); + } + return value as T; + }; + +type Coerce = (value: unknown) => unknown; + +/** + * Turns the present keys of a PATCH body into `col = $n` fragments. Keys the + * caller did not send stay untouched — every PATCH here is a partial update. + */ +function buildPatch( + body: Record, + fields: Record, +): { sets: string[]; params: unknown[] } { + const sets: string[] = []; + const params: unknown[] = []; + for (const [column, coerce] of Object.entries(fields)) { + if (!(column in body)) continue; + params.push(coerce(body[column])); + sets.push(`${column} = $${params.length}`); + } + return { sets, params }; +} + +/** Path ids come straight from the URL; a non-uuid can never match a row. */ +function badId(id: string, reply: FastifyReply, what: string): boolean { + if (UUID_RE.test(id)) return false; + reply.code(404).send({ error: `${what} not found` }); + return true; +} + +// ------------------------------------------------------------ Domain bits +/** + * A signed NDA always carries a date, and signing puts the buyer back in play. + * Idempotent, so every path that can set status = SIGNED just calls it. + */ +async function applySignedRule(tx: Tx, ndaId: string): Promise { + const nda = await tx.queryOne<{ buyer_id: string }>( + `UPDATE nda SET signed_at = coalesce(signed_at, CURRENT_DATE) + WHERE id = $1 AND status = 'SIGNED' + RETURNING buyer_id`, + [ndaId], + ); + if (!nda) return; // still SENT — nothing to do + await tx.query(`UPDATE buyer SET status = 'ACTIVE' WHERE id = $1 AND status <> 'ACTIVE'`, [ + nda.buyer_id, + ]); +} + +/** `is_primary` is a single-winner flag per buyer. */ +async function clearPrimaryFlag(tx: Tx, buyerId: string, keepContactId: string): Promise { + await tx.query( + 'UPDATE contact SET is_primary = false WHERE buyer_id = $1 AND id <> $2 AND is_primary', + [buyerId, keepContactId], + ); +} + +const openDealCount = async (buyerId: string): Promise => + ( + await queryOne<{ n: number }>( + `SELECT count(*)::int AS n FROM deal WHERE buyer_id = $1 AND status <> 'ENDED'`, + [buyerId], + ) + )?.n ?? 0; + +// ------------------------------------------------------------------ Rows +interface BuyerListRow { + id: string; + company_name: string | null; + status: BuyerStatus; + contact_name: string | null; + contact_email: string | null; + contact_phone: string | null; + nda_count: number; + open_deal_count: number; +} + +interface DuplicateRow { + buyer_id: string; + company_name: string | null; + buyer_status: BuyerStatus; + contact_name: string; + contact_email: string | null; + m_email: boolean; + m_name: boolean; + m_phone: boolean; +} + +interface DealRow { + id: string; + nda_id: string | null; + status: DealStatus; + follow_up_at: string | null; + note_count: number; + business_id: string; + business_name: string; + business_status: string; +} + +interface InquiryBody { + buyer_id?: string; + company_name?: string; + contact?: { name?: string; email?: string; phone?: string; cell?: string }; + business_id?: string; + backfill?: { + deal_status?: string; + nda_status?: string; + signed_at?: string; + nda_nas_path?: string; + }; +} + +/** + * Everything on the buyer side: buyers, contacts, NDA rounds, deals and the + * guided inquiry flow. Registered on the main app instance, so the global + * session hook in server.ts covers all of it. + */ +export function registerBuyerRoutes(app: FastifyInstance): void { + // ------------------------------------------------------------- Buyers + app.get<{ Querystring: { search?: string; status?: string } }>( + '/api/buyers', + async (req) => { + const search = req.query.search?.trim() ?? ''; + const status = req.query.status?.trim() ?? ''; + + // Like the business list: the counts always cover every status, so the + // filter chips stay usable while a filter is active. + const countParams: unknown[] = []; + let where = ''; + if (search) { + countParams.push(`%${search}%`); + const p = `$${countParams.length}`; + where = `WHERE (b.company_name ILIKE ${p} OR EXISTS ( + SELECT 1 FROM contact c WHERE c.buyer_id = b.id + AND (c.name ILIKE ${p} OR c.email ILIKE ${p})))`; + } + const counts = await query<{ status: string; n: number }>( + `SELECT b.status, count(*)::int AS n FROM buyer b ${where} GROUP BY b.status`, + countParams, + ); + + const listParams = [...countParams]; + let listWhere = where; + if (status) { + listParams.push(status); + listWhere = `${listWhere ? `${listWhere} AND` : 'WHERE'} b.status = $${listParams.length}`; + } + const rows = await query( + `SELECT b.id, b.company_name, b.status, + c.name AS contact_name, c.email AS contact_email, c.phone AS contact_phone, + n.nda_count, d.open_deal_count + FROM buyer b + LEFT JOIN LATERAL ( + SELECT name, email, phone FROM contact + WHERE buyer_id = b.id ORDER BY is_primary DESC, created_at LIMIT 1 + ) c ON true + LEFT JOIN LATERAL ( + SELECT count(*)::int AS nda_count FROM nda WHERE buyer_id = b.id + ) n ON true + LEFT JOIN LATERAL ( + SELECT count(*)::int AS open_deal_count FROM deal + WHERE buyer_id = b.id AND status <> 'ENDED' + ) d ON true + ${listWhere} + ORDER BY b.created_at DESC`, + listParams, + ); + + return { + buyers: rows.map((row) => ({ + id: row.id, + company_name: row.company_name, + status: row.status, + primary_contact: row.contact_name + ? { name: row.contact_name, email: row.contact_email, phone: row.contact_phone } + : null, + nda_count: row.nda_count, + open_deal_count: row.open_deal_count, + })), + counts: { + ACTIVE: counts.find((c) => c.status === 'ACTIVE')?.n ?? 0, + DEACTIVATED: counts.find((c) => c.status === 'DEACTIVATED')?.n ?? 0, + LEGACY: counts.find((c) => c.status === 'LEGACY')?.n ?? 0, + }, + }; + }, + ); + + /** + * Duplicate candidates for the inquiry flow: same e-mail, same name or the + * same phone number. Deliberately exact matches (after normalising) — a + * fuzzy match would cry wolf on every "John Smith" typo. + */ + app.get<{ Querystring: { email?: string; name?: string; phone?: string } }>( + '/api/buyers/duplicates', + async (req) => { + const email = trimmed(req.query.email).toLowerCase(); + const name = trimmed(req.query.name).toLowerCase(); + const phone = digits(trimmed(req.query.phone)); + const phoneKey = phone.length >= MIN_PHONE_DIGITS ? phone : ''; + + if (!email && !name && !phoneKey) return { candidates: [] }; + + const rows = await query( + `SELECT c.buyer_id, b.company_name, b.status AS buyer_status, + c.name AS contact_name, c.email AS contact_email, + ($1 <> '' AND lower(btrim(c.email)) = $1) AS m_email, + ($2 <> '' AND lower(btrim(c.name)) = $2) AS m_name, + ($3 <> '' AND $3 IN (regexp_replace(coalesce(c.phone, ''), '[^0-9]', '', 'g'), + regexp_replace(coalesce(c.cell, ''), '[^0-9]', '', 'g'))) + AS m_phone + FROM contact c + JOIN buyer b ON b.id = c.buyer_id + WHERE ($1 <> '' AND lower(btrim(c.email)) = $1) + OR ($2 <> '' AND lower(btrim(c.name)) = $2) + OR ($3 <> '' AND $3 IN (regexp_replace(coalesce(c.phone, ''), '[^0-9]', '', 'g'), + regexp_replace(coalesce(c.cell, ''), '[^0-9]', '', 'g'))) + ORDER BY c.is_primary DESC, c.created_at`, + [email, name, phoneKey], + ); + if (rows.length === 0) return { candidates: [] }; + + const buyerIds = [...new Set(rows.map((row) => row.buyer_id))]; + const rounds = await query<{ buyer_id: string; nda_count: number; last_date: string | null }>( + `SELECT buyer_id, count(*)::int AS nda_count, + max(coalesce(signed_at, intro_date, sent_at)) AS last_date + FROM nda WHERE buyer_id = ANY($1) GROUP BY buyer_id`, + [buyerIds], + ); + + // Several contacts of one buyer can match; report the buyer once with + // the union of the reasons. + const candidates = buyerIds.map((buyerId) => { + const matches = rows.filter((row) => row.buyer_id === buyerId); + const first = matches[0]!; + const matchedOn: string[] = []; + if (matches.some((row) => row.m_email)) matchedOn.push('email'); + if (matches.some((row) => row.m_name)) matchedOn.push('name'); + if (matches.some((row) => row.m_phone)) matchedOn.push('phone'); + const round = rounds.find((r) => r.buyer_id === buyerId); + return { + buyer_id: buyerId, + company_name: first.company_name, + buyer_status: first.buyer_status, + matched_on: matchedOn, + contact_name: first.contact_name, + contact_email: first.contact_email, + nda_count: round?.nda_count ?? 0, + last_date: round?.last_date ?? null, + }; + }); + return { candidates }; + }, + ); + + /** + * The guided "New inquiry" flow — one transaction so a half-created buyer + * can never survive. Returns which objects were actually created, because + * the UI says "added to existing buyer" vs "new buyer" afterwards. + */ + app.post<{ Body: InquiryBody }>('/api/inquiries', async (req, reply) => { + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + const body = req.body ?? {}; + const contactName = trimmed(body.contact?.name); + if (!contactName) return reply.code(400).send({ error: 'contact.name is missing' }); + const businessId = trimmed(body.business_id); + if (!businessId) return reply.code(400).send({ error: 'business_id is missing' }); + + const email = trimmed(body.contact?.email); + const phone = trimmed(body.contact?.phone); + const cell = trimmed(body.contact?.cell); + const companyName = trimmed(body.company_name) || null; + const buyerId = trimmed(body.buyer_id) || null; + + // Backfill of a paper record: the round may already be signed and the deal + // may already be well past NEW. + let dealStatus: DealStatus = 'NEW'; + let ndaStatus: NdaStatus = 'SENT'; + let signedAt: string | null = null; + let ndaPath: string | null = null; + try { + const backfill = body.backfill; + if (backfill) { + if (trimmed(backfill.deal_status)) { + dealStatus = asOneOf(DEAL_STATUSES)(backfill.deal_status); + } + if (trimmed(backfill.nda_status)) { + ndaStatus = asOneOf(NDA_STATUSES)(backfill.nda_status); + } + signedAt = asDate(backfill.signed_at); + ndaPath = asText(backfill.nda_nas_path); + } + } catch (err) { + if (err instanceof InputError) return reply.code(400).send({ error: err.message }); + throw err; + } + + if (!UUID_RE.test(businessId)) return reply.code(404).send({ error: 'business not found' }); + if (!(await queryOne('SELECT id FROM business WHERE id = $1', [businessId]))) { + return reply.code(404).send({ error: 'business not found' }); + } + if (buyerId) { + if (!UUID_RE.test(buyerId)) return reply.code(404).send({ error: 'buyer not found' }); + if (!(await queryOne('SELECT id FROM buyer WHERE id = $1', [buyerId]))) { + return reply.code(404).send({ error: 'buyer not found' }); + } + } + + const result = await withTransaction(async (tx) => { + let targetBuyer = buyerId; + let createdBuyer = false; + let createdContact = false; + + if (targetBuyer) { + // Same person, new round: reuse the contact instead of piling up + // near-identical rows under one buyer. + const existing = await tx.queryOne<{ id: string }>( + `SELECT id FROM contact + WHERE buyer_id = $1 + AND (($2 <> '' AND lower(btrim(email)) = $2) OR lower(btrim(name)) = $3) + ORDER BY is_primary DESC, created_at LIMIT 1`, + [targetBuyer, email.toLowerCase(), contactName.toLowerCase()], + ); + if (!existing) { + await tx.query( + 'INSERT INTO contact (buyer_id, name, email, phone, cell) VALUES ($1, $2, $3, $4, $5)', + [targetBuyer, contactName, email || null, phone || null, cell || null], + ); + createdContact = true; + } + } else { + const buyer = await tx.queryRow<{ id: string }>( + 'INSERT INTO buyer (company_name, created_by) VALUES ($1, $2) RETURNING id', + [companyName, staffId], + ); + targetBuyer = buyer.id; + createdBuyer = true; + await tx.query( + `INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary) + VALUES ($1, $2, $3, $4, $5, true)`, + [targetBuyer, contactName, email || null, phone || null, cell || null], + ); + createdContact = true; + } + + const nda = await tx.queryRow<{ id: string }>( + `INSERT INTO nda (buyer_id, status, sent_at, signed_at, nas_path, created_by) + VALUES ($1, $2, CURRENT_DATE, $3, $4, $5) RETURNING id`, + [targetBuyer, ndaStatus, signedAt, ndaPath, staffId], + ); + await applySignedRule(tx, nda.id); + + // A backfilled INFO_SENT still needs its reminder — the follow-up is due + // 14 days from when we learn about it, not from the paper date. + const deal = await tx.queryRow<{ id: string }>( + `INSERT INTO deal (buyer_id, business_id, nda_id, status, follow_up_at, created_by) + VALUES ($1, $2, $3, $4, + CASE WHEN $4 = 'INFO_SENT' THEN CURRENT_DATE + ${FOLLOW_UP_DAYS} END, $5) + RETURNING id`, + [targetBuyer, businessId, nda.id, dealStatus, staffId], + ); + + return { + buyer_id: targetBuyer, + nda_id: nda.id, + deal_id: deal.id, + created: { buyer: createdBuyer, contact: createdContact }, + }; + }); + + return reply.code(201).send(result); + }); + + // -------------------------------------------------------- Buyer detail + app.get<{ Params: { id: string } }>('/api/buyers/:id', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'buyer')) return reply; + + const buyer = await queryOne('SELECT * FROM buyer WHERE id = $1', [id]); + if (!buyer) return reply.code(404).send({ error: 'buyer not found' }); + + const contacts = await query( + `SELECT id, name, email, phone, cell, is_primary, created_at FROM contact + WHERE buyer_id = $1 ORDER BY is_primary DESC, created_at`, + [id], + ); + const ndas = await query<{ id: string }>( + `SELECT id, status, nas_path, sent_at, signed_at, intro_date, + preferred_businesses_text, total_purchase_price, down_payment, created_at + FROM nda WHERE buyer_id = $1 ORDER BY created_at DESC`, + [id], + ); + const deals = await query( + `SELECT d.id, d.nda_id, d.status, d.follow_up_at, + b.id AS business_id, b.name AS business_name, b.status AS business_status, + (SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count + FROM deal d JOIN business b ON b.id = d.business_id + WHERE d.buyer_id = $1 ORDER BY d.created_at`, + [id], + ); + + const dealsOfRound = (ndaId: string) => + deals + .filter((deal) => deal.nda_id === ndaId) + .map((deal) => ({ + id: deal.id, + status: deal.status, + follow_up_at: deal.follow_up_at, + note_count: deal.note_count, + business: { + id: deal.business_id, + name: deal.business_name, + status: deal.business_status, + }, + })); + + return { + ...buyer, + contacts, + ndas: ndas.map((nda) => ({ ...nda, deals: dealsOfRound(nda.id) })), + }; + }); + + app.patch<{ Params: { id: string }; Body: Record }>( + '/api/buyers/:id', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'buyer')) return reply; + const body = req.body ?? {}; + + let patch; + try { + patch = buildPatch(body, { + company_name: asText, + address: asText, + state: asText, + background_experience: asText, + how_heard: asText, + interested_in_updates: asNullableBool, + status: asOneOf(BUYER_STATUSES), + }); + } catch (err) { + if (err instanceof InputError) return reply.code(400).send({ error: err.message }); + throw err; + } + + const endOpenDeals = body.status === 'DEACTIVATED' && body.end_open_deals === true; + if (patch.sets.length === 0 && !endOpenDeals) { + return reply.code(400).send({ error: 'nothing to update' }); + } + + const buyer = await withTransaction(async (tx) => { + const row = + patch.sets.length === 0 + ? await tx.queryOne('SELECT * FROM buyer WHERE id = $1', [id]) + : await tx.queryOne( + `UPDATE buyer SET ${patch.sets.join(', ')} + WHERE id = $${patch.params.length + 1} RETURNING *`, + [...patch.params, id], + ); + if (!row) return null; + // Deactivating a buyer who still has running deals would leave those + // deals in the follow-up lists forever, so the caller can end them. + if (endOpenDeals) { + await tx.query( + `UPDATE deal SET status = 'ENDED', follow_up_at = NULL + WHERE buyer_id = $1 AND status <> 'ENDED'`, + [id], + ); + } + return row; + }); + if (!buyer) return reply.code(404).send({ error: 'buyer not found' }); + + return { ...buyer, open_deal_count: await openDealCount(id) }; + }, + ); + + // ------------------------------------------------------------ Contacts + app.post<{ Params: { id: string }; Body: Record }>( + '/api/buyers/:id/contacts', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'buyer')) return reply; + const body = req.body ?? {}; + + let name: string; + try { + name = asRequiredText(body.name); + } catch { + return reply.code(400).send({ error: 'name is missing' }); + } + if (!(await queryOne('SELECT id FROM buyer WHERE id = $1', [id]))) { + return reply.code(404).send({ error: 'buyer not found' }); + } + + const contact = await withTransaction(async (tx) => { + const row = await tx.queryRow<{ id: string }>( + `INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, email, phone, cell, is_primary, created_at`, + [id, name, asText(body.email), asText(body.phone), asText(body.cell), asBool(body.is_primary)], + ); + if (asBool(body.is_primary)) await clearPrimaryFlag(tx, id, row.id); + return row; + }); + return reply.code(201).send(contact); + }, + ); + + app.patch<{ Params: { id: string }; Body: Record }>( + '/api/contacts/:id', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'contact')) return reply; + const body = req.body ?? {}; + + let patch; + try { + patch = buildPatch(body, { + name: asRequiredText, + email: asText, + phone: asText, + cell: asText, + is_primary: asBool, + }); + } catch (err) { + if (err instanceof InputError) return reply.code(400).send({ error: err.message }); + throw err; + } + if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' }); + + const contact = await withTransaction(async (tx) => { + const row = await tx.queryOne<{ id: string; buyer_id: string; is_primary: boolean }>( + `UPDATE contact SET ${patch.sets.join(', ')} + WHERE id = $${patch.params.length + 1} + RETURNING id, buyer_id, name, email, phone, cell, is_primary, created_at`, + [...patch.params, id], + ); + if (!row) return null; + if (row.is_primary) await clearPrimaryFlag(tx, row.buyer_id, row.id); + return row; + }); + if (!contact) return reply.code(404).send({ error: 'contact not found' }); + return contact; + }, + ); + + // ---------------------------------------------------------- NDA rounds + app.patch<{ Params: { id: string }; Body: Record }>( + '/api/ndas/:id', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'nda')) return reply; + + let patch; + try { + patch = buildPatch(req.body ?? {}, { + status: asOneOf(NDA_STATUSES), + sent_at: asDate, + signed_at: asDate, + nas_path: asText, + preferred_businesses_text: asText, + total_purchase_price: asText, + down_payment: asText, + intro_date: asDate, + }); + } catch (err) { + if (err instanceof InputError) return reply.code(400).send({ error: err.message }); + throw err; + } + if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' }); + + const nda = await withTransaction(async (tx) => { + const row = await tx.queryOne<{ id: string }>( + `UPDATE nda SET ${patch.sets.join(', ')} + WHERE id = $${patch.params.length + 1} RETURNING id`, + [...patch.params, id], + ); + if (!row) return null; + await applySignedRule(tx, row.id); + return tx.queryOne( + `SELECT id, buyer_id, status, nas_path, sent_at, signed_at, intro_date, + preferred_businesses_text, total_purchase_price, down_payment, created_at + FROM nda WHERE id = $1`, + [row.id], + ); + }); + if (!nda) return reply.code(404).send({ error: 'nda not found' }); + return nda; + }, + ); + + /** Another business inside an existing round — no new NDA needed. */ + app.post<{ Params: { id: string }; Body: { business_id?: string } }>( + '/api/ndas/:id/deals', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'nda')) return reply; + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + const businessId = trimmed(req.body?.business_id); + if (!businessId) return reply.code(400).send({ error: 'business_id is missing' }); + if (!UUID_RE.test(businessId)) return reply.code(404).send({ error: 'business not found' }); + + const nda = await queryOne<{ buyer_id: string }>('SELECT buyer_id FROM nda WHERE id = $1', [ + id, + ]); + if (!nda) return reply.code(404).send({ error: 'nda not found' }); + if (!(await queryOne('SELECT id FROM business WHERE id = $1', [businessId]))) { + return reply.code(404).send({ error: 'business not found' }); + } + // A repeat within the same round is a mis-click; a repeat in a *new* + // round is legitimate and stays allowed. + if ( + await queryOne('SELECT id FROM deal WHERE nda_id = $1 AND business_id = $2', [ + id, + businessId, + ]) + ) { + return reply.code(409).send({ error: 'this business is already part of this NDA round' }); + } + + const deal = await queryOne( + `INSERT INTO deal (buyer_id, business_id, nda_id, created_by) + VALUES ($1, $2, $3, $4) RETURNING id, status, follow_up_at`, + [nda.buyer_id, businessId, id, staffId], + ); + return reply.code(201).send(deal); + }, + ); + + // --------------------------------------------------------------- Deals + /** + * Status change. Only a no-op is rejected: the team is small and every + * correction — including going back a step — has to stay possible. + */ + app.post<{ Params: { id: string }; Body: { status?: string; comment?: string } }>( + '/api/deals/:id/status', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'deal')) return reply; + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + let status: DealStatus; + try { + status = asOneOf(DEAL_STATUSES)(req.body?.status); + } catch (err) { + if (err instanceof InputError) return reply.code(400).send({ error: err.message }); + throw err; + } + const comment = trimmed(req.body?.comment); + + const current = await queryOne<{ status: DealStatus }>('SELECT status FROM deal WHERE id = $1', [ + id, + ]); + if (!current) return reply.code(404).send({ error: 'deal not found' }); + if (current.status === status) { + return reply.code(409).send({ error: `deal is already ${status}` }); + } + + const deal = await withTransaction(async (tx) => { + const row = await tx.queryRow( + `UPDATE deal + SET status = $2, + follow_up_at = CASE + WHEN $2 = 'INFO_SENT' THEN CURRENT_DATE + ${FOLLOW_UP_DAYS} + WHEN $2 = 'ENDED' THEN NULL + ELSE follow_up_at END + WHERE id = $1 + RETURNING id, status, follow_up_at`, + [id, status], + ); + if (comment) { + await tx.query('INSERT INTO note (text, deal_id, created_by) VALUES ($1, $2, $3)', [ + comment, + id, + staffId, + ]); + } + return row; + }); + return deal; + }, + ); + + /** Read-only; the full notes UI follows in module 5. */ + app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'deal')) return reply; + return query( + `SELECT n.id, n.text, n.highlight, n.created_at, s.name AS author + FROM note n LEFT JOIN staff s ON s.id = n.created_by + WHERE n.deal_id = $1 ORDER BY n.created_at DESC`, + [id], + ); + }); + + /** Buyer activity on a business page — the other end of the deal list. */ + app.get<{ Params: { id: string } }>('/api/businesses/:id/deals', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'business')) return reply; + return query( + `SELECT d.id, d.status, d.follow_up_at, d.created_at, + b.id AS buyer_id, b.company_name, b.status AS buyer_status, + c.name AS contact_name, + n.intro_date, n.signed_at + FROM deal d + JOIN buyer b ON b.id = d.buyer_id + LEFT JOIN nda n ON n.id = d.nda_id + LEFT JOIN LATERAL ( + SELECT name FROM contact + WHERE buyer_id = b.id ORDER BY is_primary DESC, created_at LIMIT 1 + ) c ON true + WHERE d.business_id = $1 + ORDER BY d.created_at DESC`, + [id], + ); + }); +} diff --git a/src/db.ts b/src/db.ts index 43f0804..7828dd9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,6 +1,11 @@ import pg from 'pg'; import { config } from './config.js'; +// `date` columns are calendar days, not instants. Left to node-pg they become +// Date objects at local midnight and shift by a day on the way to JSON, so we +// keep them as the plain 'YYYY-MM-DD' string Postgres already sends. +pg.types.setTypeParser(pg.types.builtins.DATE, (value) => value); + export const pool = new pg.Pool({ connectionString: config.databaseUrl, max: 10, @@ -22,3 +27,53 @@ export async function queryOne( const rows = await query(text, params); return rows[0] ?? null; } + +/** Same helpers, bound to one connection inside a transaction. */ +export interface Tx { + query( + text: string, + params?: unknown[], + ): Promise; + queryOne( + text: string, + params?: unknown[], + ): Promise; + /** For statements that must produce a row (INSERT … RETURNING, UPDATE of a known id). */ + queryRow( + text: string, + params?: unknown[], + ): Promise; +} + +/** + * Runs `fn` inside BEGIN/COMMIT on a single connection; any throw rolls back. + * The guided inquiry flow creates buyer + contact + NDA + deal together, so it + * must be all-or-nothing. + */ +export async function withTransaction(fn: (tx: Tx) => Promise): Promise { + const client = await pool.connect(); + const run = async (text: string, params: unknown[] = []) => + (await client.query(text, params)).rows; + const tx: Tx = { + query: run, + async queryOne(text: string, params: unknown[] = []) { + return (await run(text, params))[0] ?? null; + }, + async queryRow(text: string, params: unknown[] = []) { + const row = (await run(text, params))[0]; + if (!row) throw new Error('statement returned no row'); + return row; + }, + }; + try { + await client.query('BEGIN'); + const result = await fn(tx); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } +} diff --git a/src/server.ts b/src/server.ts index c06f6a8..cd7b9d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,8 @@ import { resolveBusinessFile, scanBusinesses, } from './business-scan.js'; +import { registerBuyerRoutes } from './buyer-routes.js'; +import { COOKIE, staffIdFromRequest } from './session.js'; interface Staff { id: string; @@ -32,12 +34,6 @@ interface Business { const app = Fastify({ logger: true }); await app.register(cookie); -const COOKIE = 'bizmatch_staff'; - -function staffIdFromRequest(req: { cookies: Record }): string | null { - return req.cookies[COOKIE] ?? null; -} - // ------------------------------------------------------------------ Auth /** Endpoints reachable without a session cookie. */ const PUBLIC_ROUTES = new Set(['GET /api/health', 'GET /api/staff', 'POST /api/staff', 'POST /api/login']); @@ -251,6 +247,10 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>( }, ); +// ---------------------------------------------------- Buyers / NDAs / deals +// Registered on this instance, so the session hook above covers them too. +registerBuyerRoutes(app); + // --------------------------------------------------------------- Static // Serves the built frontend in production; SPA fallback for non-/api routes. const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist'); diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..a0b4bbb --- /dev/null +++ b/src/session.ts @@ -0,0 +1,8 @@ +/** The session is just the staff id in a cookie — one small team, one LAN. */ +export const COOKIE = 'bizmatch_staff'; + +export function staffIdFromRequest(req: { + cookies: Record; +}): string | null { + return req.cookies[COOKIE] ?? null; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 989a437..e42208b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,13 +3,31 @@ import { ApiError, api, type Staff } from './api.js'; import Login from './views/Login.js'; import Businesses from './views/Businesses.js'; import BusinessDetail from './views/BusinessDetail.js'; +import Buyers from './views/Buyers.js'; +import BuyerDetail from './views/BuyerDetail.js'; +import NewInquiry from './views/NewInquiry.js'; -type View = { name: 'businesses' } | { name: 'business'; id: string }; +/** Hand-rolled routing: which view, and (for the detail views) which row. */ +type Route = + | { view: 'businesses' } + | { view: 'business'; id: string } + | { view: 'buyers' } + | { view: 'buyer'; id: string } + | { view: 'new-inquiry' }; + +const NAV: { label: string; route: Route; active: Route['view'][] }[] = [ + { label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] }, + { + label: 'Buyers', + route: { view: 'buyers' }, + active: ['buyers', 'buyer', 'new-inquiry'], + }, +]; export default function App() { const [staff, setStaff] = useState(null); const [loading, setLoading] = useState(true); - const [view, setView] = useState({ name: 'businesses' }); + const [route, setRoute] = useState({ view: 'businesses' }); useEffect(() => { api @@ -24,7 +42,7 @@ export default function App() { async function signOut() { await api.logout(); setStaff(null); - setView({ name: 'businesses' }); + setRoute({ view: 'businesses' }); } if (loading) return
Loading…
; @@ -35,12 +53,24 @@ export default function App() { // fill exactly the remaining viewport height.
- +
+ BizMatch + +
{staff.name}
- {view.name === 'businesses' ? ( -
- setView({ name: 'business', id })} /> + {route.view === 'business' ? ( +
+ setRoute({ view: 'businesses' })} + onOpenBuyer={(id) => setRoute({ view: 'buyer', id })} + />
) : ( -
- setView({ name: 'businesses' })} /> +
+ {route.view === 'businesses' && ( + setRoute({ view: 'business', id })} /> + )} + {route.view === 'buyers' && ( + setRoute({ view: 'buyer', id })} + onNewInquiry={() => setRoute({ view: 'new-inquiry' })} + /> + )} + {route.view === 'new-inquiry' && ( + setRoute({ view: 'buyer', id })} + onCancel={() => setRoute({ view: 'buyers' })} + /> + )} + {route.view === 'buyer' && ( + setRoute({ view: 'buyers' })} + onOpenBusiness={(id) => setRoute({ view: 'business', id })} + /> + )}
)}
diff --git a/web/src/api.ts b/web/src/api.ts index 65fa3c3..5e10e6a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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; +} + +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(path: string, init?: RequestInit): Promise { return (await res.json()) as T; } -function post(path: string, body?: unknown): Promise { +function send(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise { return request(path, { - method: 'POST', + 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 qs = (params: Record) => + new URLSearchParams(params).toString(); + export const api = { me: () => request('/api/me'), staff: () => request('/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( - `/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`, - ), + 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 }), + 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`), }; /** Same-origin streaming URL of one file inside a business directory. */ diff --git a/web/src/components.tsx b/web/src/components.tsx new file mode 100644 index 0000000..f5ac6ee --- /dev/null +++ b/web/src/components.tsx @@ -0,0 +1,362 @@ +import { useEffect, useRef, useState } from 'react'; +import { + api, + type BusinessListItem, + type BusinessStatus, + type BuyerStatus, + type Day, + type DealStatus, + type NdaStatus, +} from './api.js'; + +/** 'YYYY-MM-DD' is parsed by hand — new Date('…') would shift the day by the UTC offset. */ +export function formatDay(day: Day | null | undefined): string { + if (!day) return '—'; + const [year = 0, month = 1, dayOfMonth = 1] = day.split('-').map(Number); + return new Date(year, month - 1, dayOfMonth).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + }); +} + +export function formatStamp(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export const DEAL_LABELS: Record = { + NEW: 'New', + INFO_SENT: 'Info sent', + DUE_DILIGENCE: 'Due diligence', + LOI: 'LOI', + CLOSING: 'Closing', + ENDED: 'Ended', +}; + +/** The label of the *action* that puts a deal into each status. */ +export const DEAL_ACTIONS: Record = { + NEW: 'Back to new', + INFO_SENT: 'Send info', + DUE_DILIGENCE: 'Start due diligence', + LOI: 'LOI', + CLOSING: 'Closing', + ENDED: 'End deal', +}; + +/** The happy path; ENDED is reachable from everywhere and therefore not in it. */ +export const DEAL_FLOW: DealStatus[] = ['NEW', 'INFO_SENT', 'DUE_DILIGENCE', 'LOI', 'CLOSING']; + +const TONES: Record = { + ACTIVE: 'border-green-300 bg-green-50 text-green-800', + SIGNED: 'border-green-300 bg-green-50 text-green-800', + CLOSING: 'border-green-300 bg-green-50 text-green-800', + SENT: 'border-amber-300 bg-amber-50 text-amber-800', + NEW: 'border-blue-300 bg-blue-50 text-blue-800', + INFO_SENT: 'border-blue-300 bg-blue-50 text-blue-800', + DUE_DILIGENCE: 'border-indigo-300 bg-indigo-50 text-indigo-800', + LOI: 'border-indigo-300 bg-indigo-50 text-indigo-800', + DEACTIVATED: 'border-gray-300 bg-gray-100 text-gray-600', + ENDED: 'border-gray-300 bg-gray-100 text-gray-600', + LEGACY: 'border-gray-300 bg-gray-100 text-gray-600', +}; + +export function StatusBadge({ + status, + label, +}: { + status: BuyerStatus | NdaStatus | DealStatus | BusinessStatus | string; + label?: string; +}) { + const tone = TONES[status] ?? 'border-gray-300 bg-white text-gray-600'; + return ( + + {label ?? status} + + ); +} + +export function Panel({ + title, + action, + children, +}: { + title: string; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {action} +
+
{children}
+
+ ); +} + +const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm'; + +/** + * Click the value to edit it: Enter (or blur) saves, Escape cancels. Multiline + * fields get explicit buttons because Enter has to stay a newline there. + */ +export function InlineField({ + label, + value, + multiline, + type = 'text', + placeholder, + onSave, +}: { + label: string; + value: string | null; + multiline?: boolean; + type?: 'text' | 'date'; + placeholder?: string; + onSave: (next: string) => Promise | void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ''); + const [busy, setBusy] = useState(false); + + function start() { + setDraft(value ?? ''); + setEditing(true); + } + + async function save() { + if (busy) return; + if (draft === (value ?? '')) return setEditing(false); + setBusy(true); + try { + await onSave(draft); + setEditing(false); + } finally { + setBusy(false); + } + } + + const shown = type === 'date' ? formatDay(value) : value; + + return ( +
+ {label} + {editing ? ( + multiline ? ( +
+