# 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. Module 5: notes, todos and the "Today" view with the follow-up workflow. 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 5) | 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 | | POST | /api/deals/:id/follow-up-sent | `{comment?, rearm}` — note + re-arm or clear | yes | | POST | /api/notes | create a note on exactly one reference object | yes | | GET | /api/notes | notes of one object, `?…_id=` (+`include_related`) | yes | | PATCH | /api/notes/:id | edit `text` / `highlight` | yes | | DELETE | /api/notes/:id | delete a note | yes | | POST | /api/todos | create a todo | yes | | GET | /api/todos | `?assigned_to=&status=` + one ref id as scope | yes | | PATCH | /api/todos/:id | text, due_at, assigned_to, kind, document_id | yes | | POST | /api/todos/:id/done | close it (`done_by`/`done_at` = session, now) | yes | | POST | /api/todos/:id/reopen | reopen it and clear both | yes | | POST | /api/documents | pin a business file so a REVIEW todo can link it | yes | | GET | /api/today | `?staff_id=` — the day's work + nav counts | 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`. ### Notes, todos and Today (module 5) A **note** hangs off exactly one object (buyer, deal, business or NDA round), a **todo** off at most one — the DB enforces both, and the API mirrors it so a wrong body gets a `400` instead of a constraint violation. Every note and todo row carries a `context` object `{type, id, label, buyer_id, business_id}`: the label is the business name, the buyer's company/contact name or `Round `, and the two ids let the UI link straight to the right page. `GET /api/notes?buyer_id=…&include_related=true` additionally folds in the notes of that buyer's rounds and deals, which is what the buyer page shows. `GET /api/today` is the daily workqueue, narrowed to one person with `?staff_id=` and covering the whole team without it: * **todos** — OPEN, `due_at <= today`, each flagged `overdue` when `due_at < today` * **follow_ups** — deals with `follow_up_at <= today` that are not `ENDED`; "mine" means `created_by` * **pending_ndas** — rounds still `SENT` after `NDA_REMINDER_DAYS` (env, default 14); "mine" means `created_by` * **counts** — the numbers behind the nav badge Follow-ups and pending NDAs are **virtual**: they are derived from the deal and nda rows on every request and never materialise as todo rows, so there is nothing to keep in sync or clean up. Answering one is `POST /api/deals/:id/follow-up-sent`, which always writes a note and then either re-arms the reminder for another 14 days (`rearm: true`) or clears `follow_up_at` — `409` on a deal that is already ENDED. Giving up on the deal instead is the normal `POST /api/deals/:id/status` with `ENDED`. `POST /api/documents` is a stopgap for the REVIEW todo's file picker: it pins one business file (validated through the same resolver the file streaming uses) as a `document` row so `todo.document_id` can point at it. Real document management follows in module 6. ## Frontend `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state library). The header carries the nav entries **Today**, **Businesses** and **Buyers**; routing is a hand-rolled `{view, id}` state in `App.tsx`. Today is the landing view and its nav entry shows a red badge with the signed-in user's overdue + due todos. There is no polling, so the badge is refreshed on mount, on every view change and after any action that can move an item off the list. Views: * login ("Who is working?") * today — "My day" / "Team" tabs (Team groups todos by assignee and follow-ups by their creator) over three sections: todos (overdue in red, checkbox to complete, context chip opens the buyer or business), follow-ups due (with "Follow-up sent…" → comment + "wait another 14 days" / "stop waiting", and "End deal…" → the status dialog preset to ENDED) and NDA signatures pending. Empty state: "Nothing due. Enjoy your coffee." * 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 collapsed "Buyer activity", "Notes" and "Todos" panels above it * 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, a notes panel covering the buyer *and* their rounds and deals, a todos panel, 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 "Notes & todos" section per deal. Notes are written in a composer at the top of every notes panel; the red flag button marks a note as important, and flagged notes get a red left border and a light red background wherever they appear. 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 `