module 6a

This commit is contained in:
2026-07-27 17:48:40 -05:00
parent 6cb13650ed
commit e114490cde
20 changed files with 1875 additions and 116 deletions

169
README.md
View File

@@ -7,6 +7,8 @@ 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.
Module 6a: Dropbox Sign as the source of incoming NDAs — inbox, one-click
import and PDF filing on the NAS.
The UI and all domain constants are English.
@@ -74,6 +76,44 @@ npm run dev # tsx watch, migrations run on start
cd web && npm install && npm run dev
```
## Test data
The acceptance scripts run against the **real** development database, so rows
they create are visible in the UI like any other. Two rules keep that from
turning into litter:
1. **Tag it.** Every contact an acceptance run creates uses an e-mail under
`@stubtest.invalid` (a reserved TLD that can never be a real address), and
every stubbed Dropbox Sign request uses an id starting with `req-`. Those
two markers are what makes test rows identifiable later, when nobody
remembers which "Priya Raman" came from where.
2. **Remove it.** Every script ends with a cleanup step wired to `trap … EXIT`,
so it runs even when the script fails halfway:
```bash
cleanup_stub_data() {
docker compose exec -T db psql -U bizmatch bizmatch -q \
-c "DELETE FROM buyer WHERE id IN (
SELECT buyer_id FROM contact
WHERE lower(btrim(email)) LIKE '%@stubtest.invalid');" \
-c "DELETE FROM nda WHERE dropbox_sign_id LIKE 'req-%';" \
-c "DELETE FROM ds_request WHERE signature_request_id LIKE 'req-%';"
}
trap cleanup_stub_data EXIT
```
Deleting the buyer cascades to its contacts, NDA rounds, deals, notes and
todos, so the one statement is enough.
**Do not truncate `ds_request`.** It was a throwaway cache only while no API
key was configured; now it mirrors the real signature requests, and a stub run
adds its rows *alongside* them. Delete the `req-*` ones and leave
`ds_last_refresh_at` alone — a truncate would throw away the real mirror and
silently misreport when it was last refreshed.
Anything that writes files (NDA filing) must point `NDA_ROOT` at a scratch
directory for the run; never let a test write into the real NAS tree.
## NAS mount
Mount it on the host via NFS, e.g. in `/etc/fstab`:
@@ -83,8 +123,13 @@ Mount it on the host via NFS, e.g. in `/etc/fstab`:
```
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.
app container.
**Module 6a needs write access**: filing signed NDAs writes below `NDA_ROOT`
(default `<NAS_ROOT>/AA Buyers NDA's/Buyers NDA's A-Z`), so the mount has to be
`rw` rather than `ro`. Everything else the app does with the NAS is read-only;
if the mount stays read-only, imports still succeed and only report the failed
filing as a warning.
### Business directories
@@ -110,7 +155,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 5)
## API (as of module 6a)
| Method | Path | Purpose | Session |
| ------ | --------------------------- | ------------------------------------------------ | ------- |
@@ -149,6 +194,11 @@ directory aborts the scan with an error naming the path.
| 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 |
| GET | /api/nda-inbox | mirrored signature requests, `?since=<iso date>` | yes |
| POST | /api/nda-inbox/refresh | start the background walk (202 / 409 if running) | yes |
| POST | /api/nda-inbox/import | import one request into buyer/contact/nda(/deal) | yes |
| POST | /api/nda-inbox/sync | refresh the status of every pending imported NDA | yes |
| GET | /api/ndas/:id/file | stream the filed NDA PDF (Range + ETag) | yes |
Everything except health, staff (GET+POST) and login requires the session
cookie; without it the API answers `401`.
@@ -227,8 +277,8 @@ notes of that buyer's rounds and deals, which is what the buyer page shows.
`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`
* **pending_ndas** — rounds still `SENT` and not declined 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
@@ -244,12 +294,91 @@ 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.
### NDA inbox — Dropbox Sign (module 6a)
Buyers sign their NDA in Dropbox Sign, so that account is the source of new
rounds. `DROPBOX_SIGN_API_KEY` (Basic auth, key as the username, empty
password) enables the inbox routes; without it they answer `503` with a clear
message and the rest of the app is unaffected. The key lives in `.env` only —
it is never committed and never logged.
Only requests whose title starts with **`Buyer Forms - NDA`** are considered.
**The inbox is DB-backed.** Proxying the list endpoint on every view mount did
not survive contact with the real account: 700+ requests in a 90-day window
means 78 paged calls, ~74s of latency, throttling (Dropbox answers `409` as
well as `429`), and the whole list thrown away on a tab switch. So the requests
are mirrored into `ds_request` by a background task, and
* `GET /api/nda-inbox` reads **only** that table — one indexed query, instant,
unaffected by tab switches. It returns the same per-request shape as before
(status, signer, `imported`, `known_buyer` by exact normalised e-mail, up to
five `business_suggestions` by word overlap with the title remainder) plus
`last_refresh_at` and `refresh_state`.
* `POST /api/nda-inbox/refresh?since=` starts the walk and returns `202`
immediately, or `409` when one is already running — the slot is claimed with
a conditional upsert on `app_meta`, so two clicks cannot start two walks. A
`running` state left behind by a killed process is reset at startup.
The task walks the pages newest-first with a 500 ms pause between calls and
upserts every NDA request into `ds_request`; re-walking is how a row that was
pending last time is picked up as signed or declined. It stops at the first
page whose oldest entry predates the window. On `429`/`409` it honours
`Retry-After` but waits at least 10s, retries a page up to three times, and
logs the response body once per run at warn level — we still do not know what
Dropbox means by the `409` it sometimes sends. `app_meta` holds
`ds_last_refresh_at` and `ds_refresh_state` (`idle` | `running` |
`error:<msg>`).
Import and sync are unchanged and still address one `signature_request_id` at a
time. Nothing re-fetches a request per id unnecessarily: sync's candidate set
is `SENT AND NOT declined`, so signed and declined rounds are never fetched
again.
`POST /api/nda-inbox/import` is one transaction: buyer + contact (the same
reuse rules as the guided inquiry — see `ensureBuyerAndContact`), the NDA round
with `dropbox_sign_id` (UNIQUE, so a second import is a `409`), and optionally
a deal. A signed request becomes `SIGNED` and runs the usual signed rule.
`custom_fields` are ignored — field extraction is a later module.
**Declined rounds** stay `SENT` with `declined = true`: they were never signed,
but they must not look merely outstanding either. They are therefore excluded
from the sync candidate set (a decline is final on the Dropbox side) *and* from
the `pending_ndas` section of the Today view (nobody is waiting for that
signature). So that the event does not vanish silently, flipping to declined —
whether on import or during a sync — writes a highlighted note on the buyer:
`NDA declined via Dropbox Sign, <date of the round>`.
Signed PDFs are filed under `NDA_ROOT`:
```
NDA_ROOT/<letter>/active/<Last>, <First> <YYYY-MM-DD> NDA.pdf buyer is active
NDA_ROOT/<letter>/<Last>, <First> <YYYY-MM-DD> NDA.pdf buyer is not
```
`<letter>` is the first letter of the last name, uppercased; anything outside
AZ is filed under `_`. Collisions get ` (2)`, ` (3)` … before the extension.
Setting a buyer to `DEACTIVATED` moves their filed NDAs out of `active/` and
`ACTIVE` moves them back; each move is wrapped individually, and a failure only
adds `{nda_id, error}` to the `warnings` array of the PATCH response — the
status change itself always stands. The same holds for the import: a PDF that
cannot be downloaded or written returns a `warning` instead of rolling back an
import that already succeeded.
`POST /api/nda-inbox/sync` re-checks every NDA that still has status `SENT`, a
`dropbox_sign_id` and `declined = false`; it applies the signed rule and files
the PDF for the ones that came in, flags the ones that were declined, and
answers with `{checked, signed, declined, failed, warnings}`.
`DROPBOX_SIGN_BASE_URL` exists so the whole flow can be exercised against a
local stub; leave it unset in production.
## 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
library). The header carries the nav entries **Today**, **NDA Inbox**,
**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:
@@ -261,6 +390,14 @@ Views:
"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."
* nda inbox — renders straight from the mirror on mount, never from Dropbox.
The header shows "Last refreshed: &lt;relative time&gt;"; Refresh starts the
background walk and polls every 3s until it reports idle, then reloads the
table, while the table stays usable throughout. Plus "Sync signatures", and
one row per request with a status chip, a known-buyer chip linking to the
buyer (or "new"), a business select preselected to the best suggestion
("— no deal —" to import the round on its own) and an Import button.
Imported rows show a checkmark that opens the buyer instead.
* 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"
@@ -278,7 +415,9 @@ Views:
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.
per deal. A round with a filed PDF gets a "View PDF" button opening the
pdf.js viewer on `/api/ndas/:id/file`; a declined round carries a red
"Declined" badge.
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
@@ -327,16 +466,22 @@ migrations/ numbered SQL migrations
002_buyer_fields.sql buyer-side fields from the NDA form + intake sheet
003_…_nullable.sql interested_in_updates becomes tri-state
004_reset_….sql one-time reset of that column to NULL
005_nda_dropbox_….sql dropbox_sign_id, signer_name, declined on nda
006_ds_request_….sql ds_request mirror + the app_meta key/value table
src/
config.ts env configuration
db.ts pg pool, query helpers, withTransaction
session.ts the staff-id cookie
http.ts input coercion + PATCH/reference helpers for the routes
http.ts input coercion, PATCH/reference helpers, file responses
migrate.ts migration runner (transactional, advisory lock)
business-scan.ts NAS scan, recursive listing, safe file path resolution
dropbox-sign.ts thin Dropbox Sign REST client (list, get, download)
nda-files.ts naming, filing and active/inactive moves of NDA PDFs
nda-refresh.ts the background walk that mirrors requests into ds_request
server.ts Fastify app (health, staff, login, businesses, file, static)
buyer-routes.ts buyers, contacts, NDA rounds, deals, the inquiry flow
workflow-routes.ts notes, todos, documents, the Today view, follow-ups
nda-inbox-routes.ts the NDA inbox, import, signature sync, NDA PDF streaming
web/
scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild)
public/viewer/ standalone, unbundled pdf.js viewer page
@@ -345,7 +490,7 @@ web/
src/App.tsx session gate + nav (with the Today badge) + view switch
src/components.tsx shared bits (badges, inline fields, business picker, dialog)
src/workflow.tsx notes panel, todo list and the add-todo dialog
src/views/ Login, Today, Businesses, BusinessDetail, Buyers,
BuyerDetail, NewInquiry
src/views/ Login, Today, NdaInbox, Businesses, BusinessDetail,
Buyers, BuyerDetail, NewInquiry
viewer-phase1/ reference copy of the phase-1 desktop viewer
```