# BizMatch Phase 2 — Broker Workflow App 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. ## Setup on 192.168.100.99 (Ubuntu 24.04) Prerequisite: Docker + Compose plugin (`sudo apt install docker.io docker-compose-v2`). ```bash # Unpack the project, then: cd bizmatch-app cp .env.example .env # optionally adjust DB_PASSWORD docker compose up -d --build ``` The app applies all migrations on start and then listens on `http://192.168.100.99:8090`. > **Upgrading from Module 1:** `001_init.sql` was rewritten in place (German > constants -> English). Applying it needs a fresh database: > `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 curl http://localhost:8090/api/health # -> {"ok":true} # Create the three staff members (adjust the names): curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"Chris"}' curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"..."}' ``` ## Dev mode (without the app container) ```bash docker compose up -d db # database only npm install set -a; source .env; set +a npm run dev # tsx watch, migrations run on start # second terminal — frontend with hot reload, /api is proxied to :8090 cd web && npm install && npm run dev ``` ## NAS mount Mount it on the host via NFS, e.g. in `/etc/fstab`: ``` :/mnt//bizmatch /mnt/bizmatch-nas nfs ro,soft,timeo=100 0 0 ``` The compose file already passes `NAS_ROOT` (default `/mnt/bizmatch-nas`) into the app container. Write access (NDA filing) comes in module 6 — then replace `ro` with `rw` and limit the permissions to the two write paths. ### Business directories Directly below `NAS_ROOT` there are three status directories; every immediate subdirectory of those is one business. The directory names are configurable (they contain spaces and are treated as opaque strings): | Env var | Default | Business status | | ------------------ | -------------- | --------------- | | `NAS_DIR_ACTIVE` | `AAA = ACTIVE` | ACTIVE | | `NAS_DIR_SOLD` | `AAA = SOLD` | SOLD | | `NAS_DIR_INACTIVE` | `AAA = INACTIVE` | INACTIVE | The scan is idempotent: existing rows are matched by name and only updated when `nas_path` or `status` changed. Businesses that exist in the DB but no longer on disk are kept and only reported as a warning in the log. A missing configured directory aborts the scan with an error naming the path. ## Moving to the AI machine (later) 1. `docker compose down` on .99 2. Take a dump: `docker compose exec db pg_dump -U bizmatch bizmatch > backup.sql` 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 4) | Method | Path | Purpose | Session | | ------ | --------------------------- | ------------------------------------------------ | ------- | | GET | /api/health | liveness incl. DB check | no | | GET | /api/staff | staff list | no | | POST | /api/staff | create staff member `{name}` | no | | POST | /api/login | login via `{staff_id}` → session cookie | no | | GET | /api/me | signed-in staff member | yes | | POST | /api/logout | sign out | yes | | POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes | | GET | /api/businesses | list `?status=&search=` + counts per status | yes | | 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 dotfiles, dot-directories and symlinks, and returns `{ path, size, mtime }` with `path` relative to the business directory and always posix-separated. PDFs come first, then everything else, each group alphabetical. `/file?path=…` streams one of those files straight from disk (`createReadStream`, never buffered): * the path is resolved against `nas_path` and then `realpath`-validated to be inside `realpath(business dir)`. Absolute paths, `..`, leading dots, empty paths and symlinks pointing out of the tree get `400`; a missing file `404`. * single-range HTTP `Range` requests answer `206` with `Content-Range`, unsatisfiable ones `416`. * `ETag` is derived from mtime + size, `If-None-Match` answers `304`. * `.pdf` is served as `application/pdf` (inline), anything else as `application/octet-stream` with `Content-Disposition: attachment`. ## Frontend `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state 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 `index.html` for all non-`/api` routes; the Dockerfile builds the frontend in its own stage and copies `web/dist` into the runtime image. ### PDF viewer The viewer is the proven one from the phase-1 Deno desktop app (see `viewer-phase1/`), ported nearly byte-identical. It lives in `web/public/viewer/` as plain, unbundled ES modules — Vite serves `public/` as-is, so the same files work in dev and prod. The React app embeds it in an `