module 4
This commit is contained in:
112
README.md
112
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=<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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user