2026-07-26 18:19:25 -05:00
2026-07-26 18:19:25 -05:00
2026-07-26 18:19:25 -05:00
2026-07-26 18:19:25 -05:00
2026-07-23 17:48:44 -05:00
2026-07-26 18:19:25 -05:00
2026-07-26 17:45:58 -05:00
2026-07-23 17:48:44 -05:00
2026-07-26 17:45:58 -05:00
2026-07-23 17:48:44 -05:00
2026-07-23 17:48:44 -05:00
2026-07-23 17:48:44 -05:00
2026-07-23 17:48:44 -05:00
2026-07-26 18:19:25 -05:00
2026-07-23 17:48:44 -05:00

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).

# 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:

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)

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:

<truenas-ip>:/mnt/<pool>/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=<relative> 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 <iframe>:

/viewer/index.html?file=<urlencoded /api/businesses/:id/file?path=...>

The page refuses any file value that is not a root-relative /api/ path, and the iframe is same-origin, so the normal session cookie authenticates it.

web/public/pdfjs/ holds the pdf.js runtime, copied out of node_modules/pdfjs-dist (pinned to exactly 6.1.200) by web/scripts/copy-pdfjs.mjs, which runs on predev and prebuild — also inside the Docker web stage. The directory is generated and git-ignored:

web/public/pdfjs/legacy/         pdf.min.mjs + pdf.worker.min.mjs
web/public/pdfjs/wasm/           CCITT-G4/JBIG2, JPEG2000 and ICC decoders
web/public/pdfjs/standard_fonts/ standardFontDataUrl
web/public/pdfjs/iccs/           iccUrl

The wasm/ directory is what makes scanned B/W pages render at all; without it pdf.js fails the decoders silently and shows blank white canvases.

Structure

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, 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 + 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
Description
No description provided
Readme 322 KiB
Languages
TypeScript 78.2%
JavaScript 16.4%
PLpgSQL 2.4%
CSS 1.9%
HTML 0.9%
Other 0.2%