module 6a
This commit is contained in:
169
README.md
169
README.md
@@ -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 7–8 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
|
||||
A–Z 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: <relative time>"; 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user