From e114490cdee08f30cf0edb8b7a2af97342ccc7d5 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 27 Jul 2026 17:48:40 -0500 Subject: [PATCH] module 6a --- .claude/settings.local.json | 22 +- .env.example | 6 + README.md | 169 +++++++++- docker-compose.yml | 2 + migrations/005_nda_dropbox_sign.sql | 11 + migrations/006_ds_request_cache.sql | 30 ++ src/buyer-routes.ts | 139 +++++--- src/config.ts | 6 + src/dropbox-sign.ts | 157 +++++++++ src/http.ts | 74 ++++- src/nda-files.ts | 161 ++++++++++ src/nda-inbox-routes.ts | 478 ++++++++++++++++++++++++++++ src/nda-refresh.ts | 198 ++++++++++++ src/server.ts | 65 +--- src/workflow-routes.ts | 4 +- web/src/App.tsx | 16 +- web/src/api.ts | 81 ++++- web/src/components.tsx | 37 +++ web/src/views/BuyerDetail.tsx | 22 ++ web/src/views/NdaInbox.tsx | 313 ++++++++++++++++++ 20 files changed, 1875 insertions(+), 116 deletions(-) create mode 100644 migrations/005_nda_dropbox_sign.sql create mode 100644 migrations/006_ds_request_cache.sql create mode 100644 src/dropbox-sign.ts create mode 100644 src/nda-files.ts create mode 100644 src/nda-inbox-routes.ts create mode 100644 src/nda-refresh.ts create mode 100644 web/src/views/NdaInbox.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b49c782..d1c7fd7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -41,7 +41,27 @@ "Bash(curl -s localhost:8090/api/health)", "Bash(curl -s -m 3 localhost:8091/api/health)", "Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance5.sh 2>&1)", - "Bash(npm --prefix web run typecheck)" + "Bash(npm --prefix web run typecheck)", + "Bash(python3 -)", + "Bash(cd *)", + "Bash(curl -s -m 3 http://127.0.0.1:8099/__auth)", + "Bash(node sign-stub.mjs)", + "Bash(curl -s -m 2 localhost:8091/api/health)", + "Bash(fuser -k 8092/tcp)", + "Bash(npm --prefix web run build)", + "Bash(curl -s -m 2 http://127.0.0.1:8099/__auth)", + "Bash(curl -s localhost:8091/api/health)", + "Bash(NDA_ROOT=__TRACKED_VAR__/nda-root bash __TRACKED_VAR__/acceptance6.sh)", + "Bash(node --check sign-stub.mjs)", + "Bash(fuser -k 8099/tcp)", + "Bash(fuser -k 8091/tcp)", + "Bash(curl -s -m 2 http://127.0.0.1:8099/__stats)", + "Bash(curl -s http://127.0.0.1:8099/__stats)", + "Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance6b.sh 2>&1)", + "Bash(curl -s http://127.0.0.1:8099/__reset-stats)", + "Bash(bash -n acceptance6.sh)", + "Bash(bash -n acceptance6b.sh)", + "Bash(curl -s -m 3 http://127.0.0.1:8099/__stats)" ] } } diff --git a/.env.example b/.env.example index 265d05d..3170452 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ NAS_ROOT=/mnt/bizmatch-nas # An NDA that is still SENT after this many days is reported in the Today view NDA_REMINDER_DAYS=14 +# Dropbox Sign: the NDA inbox routes answer 503 until this is set. +# Keep the real key in .env only — .env is gitignored and the key is never logged. +DROPBOX_SIGN_API_KEY= +# Where signed NDAs are filed (one directory per first letter of the last name) +NDA_ROOT=/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z + # Directory names directly below NAS_ROOT, one per business status NAS_DIR_ACTIVE="AAA = ACTIVE" NAS_DIR_SOLD="AAA = SOLD" diff --git a/README.md b/README.md index 82853f2..958f88d 100644 --- a/README.md +++ b/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 `/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=` | 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:`). + +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, `. + +Signed PDFs are filed under `NDA_ROOT`: + +``` +NDA_ROOT//active/, NDA.pdf buyer is active +NDA_ROOT//, NDA.pdf buyer is not +``` + +`` 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 ``` diff --git a/docker-compose.yml b/docker-compose.yml index 5fe736c..5e52e4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: PORT: "8090" NAS_ROOT: ${NAS_ROOT:-/mnt/bizmatch-nas} NDA_REMINDER_DAYS: ${NDA_REMINDER_DAYS:-14} + DROPBOX_SIGN_API_KEY: ${DROPBOX_SIGN_API_KEY:-} + NDA_ROOT: ${NDA_ROOT:-/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z} NAS_DIR_ACTIVE: ${NAS_DIR_ACTIVE:-AAA = ACTIVE} NAS_DIR_SOLD: ${NAS_DIR_SOLD:-AAA = SOLD} NAS_DIR_INACTIVE: ${NAS_DIR_INACTIVE:-AAA = INACTIVE} diff --git a/migrations/005_nda_dropbox_sign.sql b/migrations/005_nda_dropbox_sign.sql new file mode 100644 index 0000000..9145258 --- /dev/null +++ b/migrations/005_nda_dropbox_sign.sql @@ -0,0 +1,11 @@ +-- BizMatch Phase 2 — module 6a: NDAs arrive from Dropbox Sign +-- +-- dropbox_sign_id is the import anchor: UNIQUE, so the same signature request +-- can never be imported twice, and NULL for every round entered by hand. + +ALTER TABLE nda + ADD COLUMN dropbox_sign_id text UNIQUE, + ADD COLUMN signer_name text, + -- A declined round keeps status SENT (it was never signed) but must be + -- distinguishable from one that is merely still outstanding. + ADD COLUMN declined boolean NOT NULL DEFAULT false; diff --git a/migrations/006_ds_request_cache.sql b/migrations/006_ds_request_cache.sql new file mode 100644 index 0000000..b775d5e --- /dev/null +++ b/migrations/006_ds_request_cache.sql @@ -0,0 +1,30 @@ +-- BizMatch Phase 2 — module 6a, second cut: the NDA inbox reads from the DB +-- +-- Proxying the Dropbox Sign list API on every view mount meant 7-8 paged calls +-- and ~74s for a 90-day window, rate-limit errors, and the whole list lost on +-- a tab switch. The requests are now mirrored here by a background refresh and +-- the inbox only ever reads this table. + +CREATE TABLE ds_request ( + signature_request_id text PRIMARY KEY, + title text, + created_at timestamptz, + status text NOT NULL CHECK (status IN ('pending', 'signed', 'declined')), + signer_name text, + signer_email text, + signed_at timestamptz, + -- When we last saw this row on the Dropbox side. + fetched_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ds_request_created_idx ON ds_request (created_at DESC); +-- The inbox resolves the known buyer over this, same normalisation as contact. +CREATE INDEX ds_request_email_idx ON ds_request (lower(btrim(signer_email))) + WHERE signer_email IS NOT NULL; + +-- Small key/value store for state that has no natural home. Currently +-- ds_last_refresh_at and ds_refresh_state (idle | running | error:). +CREATE TABLE app_meta ( + key text PRIMARY KEY, + value text, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/src/buyer-routes.ts b/src/buyer-routes.ts index 76c0587..4c1a006 100644 --- a/src/buyer-routes.ts +++ b/src/buyer-routes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { query, queryOne, withTransaction, type Tx } from './db.js'; import { staffIdFromRequest } from './session.js'; +import { moveNdaFile } from './nda-files.js'; import { InputError, UUID_RE, @@ -47,7 +48,7 @@ const digits = (value: string): string => value.replace(/\D/g, ''); * A signed NDA always carries a date, and signing puts the buyer back in play. * Idempotent, so every path that can set status = SIGNED just calls it. */ -async function applySignedRule(tx: Tx, ndaId: string): Promise { +export async function applySignedRule(tx: Tx, ndaId: string): Promise { const nda = await tx.queryOne<{ buyer_id: string }>( `UPDATE nda SET signed_at = coalesce(signed_at, CURRENT_DATE) WHERE id = $1 AND status = 'SIGNED' @@ -60,6 +61,67 @@ async function applySignedRule(tx: Tx, ndaId: string): Promise { ]); } +export interface ContactInput { + name: string; + email?: string; + phone?: string; + cell?: string; +} + +/** + * The buyer/contact half of an inquiry, shared by the guided flow and the NDA + * inbox import so both behave identically: an existing buyer keeps its + * contacts unless this is genuinely a new person (different normalised e-mail + * *and* a different name), a new buyer gets the contact as its primary one. + */ +export async function ensureBuyerAndContact( + tx: Tx, + { + buyerId, + companyName, + contact, + staffId, + }: { + buyerId: string | null; + companyName: string | null; + contact: ContactInput; + staffId: string; + }, +): Promise<{ buyerId: string; createdBuyer: boolean; createdContact: boolean }> { + const name = contact.name.trim(); + const email = (contact.email ?? '').trim(); + const values = [name, email || null, contact.phone?.trim() || null, contact.cell?.trim() || null]; + + if (buyerId) { + // Same person, new round: reuse the contact instead of piling up + // near-identical rows under one buyer. + const existing = await tx.queryOne<{ id: string }>( + `SELECT id FROM contact + WHERE buyer_id = $1 + AND (($2 <> '' AND lower(btrim(email)) = $2) OR lower(btrim(name)) = $3) + ORDER BY is_primary DESC, created_at LIMIT 1`, + [buyerId, email.toLowerCase(), name.toLowerCase()], + ); + if (existing) return { buyerId, createdBuyer: false, createdContact: false }; + await tx.query( + 'INSERT INTO contact (buyer_id, name, email, phone, cell) VALUES ($1, $2, $3, $4, $5)', + [buyerId, ...values], + ); + return { buyerId, createdBuyer: false, createdContact: true }; + } + + const buyer = await tx.queryRow<{ id: string }>( + 'INSERT INTO buyer (company_name, created_by) VALUES ($1, $2) RETURNING id', + [companyName, staffId], + ); + await tx.query( + `INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary) + VALUES ($1, $2, $3, $4, $5, true)`, + [buyer.id, ...values], + ); + return { buyerId: buyer.id, createdBuyer: true, createdContact: true }; +} + /** `is_primary` is a single-winner flag per buyer. */ async function clearPrimaryFlag(tx: Tx, buyerId: string, keepContactId: string): Promise { await tx.query( @@ -322,41 +384,16 @@ export function registerBuyerRoutes(app: FastifyInstance): void { } const result = await withTransaction(async (tx) => { - let targetBuyer = buyerId; - let createdBuyer = false; - let createdContact = false; - - if (targetBuyer) { - // Same person, new round: reuse the contact instead of piling up - // near-identical rows under one buyer. - const existing = await tx.queryOne<{ id: string }>( - `SELECT id FROM contact - WHERE buyer_id = $1 - AND (($2 <> '' AND lower(btrim(email)) = $2) OR lower(btrim(name)) = $3) - ORDER BY is_primary DESC, created_at LIMIT 1`, - [targetBuyer, email.toLowerCase(), contactName.toLowerCase()], - ); - if (!existing) { - await tx.query( - 'INSERT INTO contact (buyer_id, name, email, phone, cell) VALUES ($1, $2, $3, $4, $5)', - [targetBuyer, contactName, email || null, phone || null, cell || null], - ); - createdContact = true; - } - } else { - const buyer = await tx.queryRow<{ id: string }>( - 'INSERT INTO buyer (company_name, created_by) VALUES ($1, $2) RETURNING id', - [companyName, staffId], - ); - targetBuyer = buyer.id; - createdBuyer = true; - await tx.query( - `INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary) - VALUES ($1, $2, $3, $4, $5, true)`, - [targetBuyer, contactName, email || null, phone || null, cell || null], - ); - createdContact = true; - } + const { + buyerId: targetBuyer, + createdBuyer, + createdContact, + } = await ensureBuyerAndContact(tx, { + buyerId, + companyName, + contact: { name: contactName, email, phone, cell }, + staffId, + }); const nda = await tx.queryRow<{ id: string }>( `INSERT INTO nda (buyer_id, status, sent_at, signed_at, nas_path, created_by) @@ -401,7 +438,8 @@ export function registerBuyerRoutes(app: FastifyInstance): void { ); const ndas = await query<{ id: string }>( `SELECT id, status, nas_path, sent_at, signed_at, intro_date, - preferred_businesses_text, total_purchase_price, down_payment, created_at + preferred_businesses_text, total_purchase_price, down_payment, + dropbox_sign_id, signer_name, declined, created_at FROM nda WHERE buyer_id = $1 ORDER BY created_at DESC`, [id], ); @@ -487,7 +525,29 @@ export function registerBuyerRoutes(app: FastifyInstance): void { }); if (!buyer) return reply.code(404).send({ error: 'buyer not found' }); - return { ...buyer, open_deal_count: await openDealCount(id) }; + // Filed NDAs live in /active/ while the buyer is active and one + // level up once they are not. A failed move must never undo the status + // change itself — the NAS may simply be read-only or unreachable. + const warnings: { nda_id: string; error: string }[] = []; + if (body.status === 'DEACTIVATED' || body.status === 'ACTIVE') { + const active = body.status === 'ACTIVE'; + const filed = await query<{ id: string; nas_path: string }>( + 'SELECT id, nas_path FROM nda WHERE buyer_id = $1 AND nas_path IS NOT NULL', + [id], + ); + for (const nda of filed) { + try { + const moved = await moveNdaFile(nda.nas_path, { active }); + if (moved) await query('UPDATE nda SET nas_path = $1 WHERE id = $2', [moved, nda.id]); + } catch (err) { + const message = (err as Error).message; + app.log.warn(`[nda-files] moving ${nda.id} failed: ${message}`); + warnings.push({ nda_id: nda.id, error: message }); + } + } + } + + return { ...buyer, open_deal_count: await openDealCount(id), warnings }; }, ); @@ -596,7 +656,8 @@ export function registerBuyerRoutes(app: FastifyInstance): void { await applySignedRule(tx, row.id); return tx.queryOne( `SELECT id, buyer_id, status, nas_path, sent_at, signed_at, intro_date, - preferred_businesses_text, total_purchase_price, down_payment, created_at + preferred_businesses_text, total_purchase_price, down_payment, + dropbox_sign_id, signer_name, declined, created_at FROM nda WHERE id = $1`, [row.id], ); diff --git a/src/config.ts b/src/config.ts index a0c4342..0e468a7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,12 @@ export const config = { nasRoot: process.env.NAS_ROOT ?? '/mnt/bizmatch-nas', /** After this many days an NDA that is still SENT shows up in the Today view */ ndaReminderDays: Number(process.env.NDA_REMINDER_DAYS ?? 14), + /** Dropbox Sign API key — without it the NDA inbox routes answer 503. */ + dropboxSignApiKey: process.env.DROPBOX_SIGN_API_KEY ?? '', + /** Overridable so the inbox can be exercised against a local stub in tests. */ + dropboxSignBaseUrl: process.env.DROPBOX_SIGN_BASE_URL ?? 'https://api.hellosign.com', + /** Where signed NDAs are filed, one directory per first letter of the last name */ + ndaRoot: process.env.NDA_ROOT ?? "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z", /** Directory names directly below NAS_ROOT, one per business status */ nasDirActive: process.env.NAS_DIR_ACTIVE ?? 'AAA = ACTIVE', nasDirSold: process.env.NAS_DIR_SOLD ?? 'AAA = SOLD', diff --git a/src/dropbox-sign.ts b/src/dropbox-sign.ts new file mode 100644 index 0000000..388c8ba --- /dev/null +++ b/src/dropbox-sign.ts @@ -0,0 +1,157 @@ +import { config } from './config.js'; + +/** + * Thin Dropbox Sign REST client — only the three calls this app makes. + * Auth is Basic with the API key as the username and an empty password; the + * key itself is never logged, only whether it is configured. + */ + +/** Only requests whose title starts with this are ours. */ +export const NDA_TITLE_PREFIX = 'Buyer Forms - NDA'; + +const TIMEOUT_MS = 15_000; + +export interface SignatureSummary { + signer_name: string | null; + signer_email_address: string | null; + status_code: string | null; + signed_at: number | null; +} + +export interface SignatureRequest { + signature_request_id: string; + title: string; + /** Unix seconds. */ + created_at: number; + is_complete: boolean; + is_declined: boolean; + files_url: string; + signatures: SignatureSummary[]; +} + +export class DropboxSignError extends Error { + constructor( + readonly status: number, + message: string, + /** Raw response body — logged once per refresh run so we learn what + * Dropbox actually complains about on a throttle. */ + readonly body?: string, + readonly retryAfterSeconds?: number, + ) { + super(message); + } +} + +/** Dropbox throttles with 429, and has been seen answering 409 for the same. */ +export const isRateLimited = (err: unknown): err is DropboxSignError => + err instanceof DropboxSignError && (err.status === 429 || err.status === 409); + +export const isConfigured = (): boolean => config.dropboxSignApiKey !== ''; + +function authHeader(): string { + // API key as username, empty password. + return `Basic ${Buffer.from(`${config.dropboxSignApiKey}:`).toString('base64')}`; +} + +/** One retry, and only for the failures that are worth retrying. */ +async function fetchWithRetry(url: string, accept: string): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const res = await fetch(url, { + headers: { authorization: authHeader(), accept }, + signal: AbortSignal.timeout(TIMEOUT_MS), + redirect: 'follow', + }); + if (res.status >= 500) { + lastError = new DropboxSignError(res.status, `Dropbox Sign answered ${res.status}`); + continue; + } + return res; + } catch (err) { + // Timeouts and network errors — the URL may carry no secrets, but the + // message is kept generic anyway. + lastError = new DropboxSignError(502, `Dropbox Sign unreachable: ${(err as Error).message}`); + } + } + throw lastError ?? new DropboxSignError(502, 'Dropbox Sign unreachable'); +} + +async function getJson(path: string): Promise { + const res = await fetchWithRetry(`${config.dropboxSignBaseUrl}${path}`, 'application/json'); + if (!res.ok) { + const body = await res.text().catch(() => ''); + const retryAfter = Number(res.headers.get('retry-after') ?? ''); + throw new DropboxSignError( + res.status, + `Dropbox Sign answered ${res.status} for ${path}`, + body.slice(0, 500), + Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined, + ); + } + return (await res.json()) as T; +} + +export interface SignatureRequestPage { + /** Everything on the page, unfiltered — the caller needs the oldest entry. */ + requests: SignatureRequest[]; + /** Only the ones this app cares about. */ + ndaRequests: SignatureRequest[]; + page: number; + numPages: number; +} + +/** + * One page of the list endpoint, newest first. Paging is driven by the caller + * (see nda-refresh.ts) so it can pace the calls and handle throttling. + */ +export async function listPage(page: number, pageSize = 100): Promise { + const body = await getJson<{ + signature_requests?: SignatureRequest[]; + list_info?: { num_pages?: number; page?: number }; + }>(`/v3/signature_request/list?page=${page}&page_size=${pageSize}`); + const requests = body.signature_requests ?? []; + return { + requests, + ndaRequests: requests.filter((request) => request.title?.startsWith(NDA_TITLE_PREFIX)), + page: body.list_info?.page ?? page, + numPages: body.list_info?.num_pages ?? 1, + }; +} + +export async function getSignatureRequest(id: string): Promise { + const body = await getJson<{ signature_request?: SignatureRequest }>( + `/v3/signature_request/${encodeURIComponent(id)}`, + ); + if (!body.signature_request) { + throw new DropboxSignError(404, `signature request ${id} not found`); + } + return body.signature_request; +} + +/** + * The signed PDF. `files_url` redirects to storage, and undici drops the + * Authorization header on the cross-origin hop, which is what we want. + */ +export async function downloadFile(request: SignatureRequest): Promise { + const url = + request.files_url || + `${config.dropboxSignBaseUrl}/v3/signature_request/files/${encodeURIComponent( + request.signature_request_id, + )}`; + const res = await fetchWithRetry(url, 'application/pdf'); + if (!res.ok) { + throw new DropboxSignError(res.status, `downloading the PDF answered ${res.status}`); + } + return Buffer.from(await res.arrayBuffer()); +} + +/** pending | signed | declined, derived from the flags the API sets. */ +export function statusOf(request: SignatureRequest): 'pending' | 'signed' | 'declined' { + if (request.is_declined) return 'declined'; + if (request.is_complete) return 'signed'; + const code = request.signatures?.[0]?.status_code ?? ''; + if (code === 'declined') return 'declined'; + if (code === 'signed') return 'signed'; + return 'pending'; +} diff --git a/src/http.ts b/src/http.ts index 5b4c0b4..5635158 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,4 +1,5 @@ -import type { FastifyReply } from 'fastify'; +import { createReadStream } from 'node:fs'; +import type { FastifyReply, FastifyRequest } from 'fastify'; /** * Input coercion shared by the route modules. Every coercer either returns the @@ -111,6 +112,77 @@ export interface Refs { id: string | null; } +// -------------------------------------------------------- File responses +/** Parses a single-range `Range` header. null = ignore, 'invalid' = 416. */ +export function parseRange( + header: string | undefined, + size: number, +): { start: number; end: number } | null | 'invalid' { + if (!header) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!match) return null; // multi-range or garbage: answer with the full body + const [, rawStart, rawEnd] = match; + if (rawStart === '' && rawEnd === '') return 'invalid'; + + let start: number; + let end: number; + if (rawStart === '') { + // suffix range: the last N bytes + const suffix = Number(rawEnd); + if (suffix === 0) return 'invalid'; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = Number(rawStart); + end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1); + } + if (start > end || start >= size) return 'invalid'; + return { start, end }; +} + +/** + * Streams a file off disk with ETag, conditional GET and single-range support. + * Shared by the business file and NDA file endpoints so both behave alike. + */ +export function sendFile( + req: FastifyRequest, + reply: FastifyReply, + file: { absPath: string; size: number; mtimeMs: number; name: string }, +): FastifyReply { + const etag = `"${file.mtimeMs.toString(16)}-${file.size.toString(16)}"`; + reply.header('ETag', etag); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch && ifNoneMatch.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) { + return reply.code(304).send(); + } + + const range = parseRange(req.headers.range, file.size); + if (range === 'invalid') { + reply.header('Content-Range', `bytes */${file.size}`); + return reply.code(416).send({ error: 'range not satisfiable' }); + } + + const isPdf = file.name.toLowerCase().endsWith('.pdf'); + reply.header('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); + reply.header( + 'Content-Disposition', + `${isPdf ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`, + ); + + if (range) { + reply.code(206); + reply.header('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`); + reply.header('Content-Length', range.end - range.start + 1); + return reply.send(createReadStream(file.absPath, { start: range.start, end: range.end })); + } + + reply.header('Content-Length', file.size); + return reply.send(createReadStream(file.absPath)); +} + export function readRefs(source: Record): Refs { const present = REF_COLUMNS.filter((column) => trimmed(source[column]) !== ''); if (present.length > 1) { diff --git a/src/nda-files.ts b/src/nda-files.ts new file mode 100644 index 0000000..febe947 --- /dev/null +++ b/src/nda-files.ts @@ -0,0 +1,161 @@ +import { access, mkdir, realpath, rename, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { config } from './config.js'; + +/** + * Filing of signed NDA PDFs on the NAS. + * + * Layout: NDA_ROOT//active/, NDA.pdf while + * the buyer is active, and one level up in NDA_ROOT// once they are + * deactivated. The letter is the first letter of the signer's last name. + */ + +/** Directory holding the files of buyers who are currently active. */ +const ACTIVE_DIR = 'active'; + +export class NdaFileError extends Error { + constructor( + readonly status: 400 | 404, + message: string, + ) { + super(message); + } +} + +/** Anything that could leave the intended directory or upset a filesystem. */ +const sanitize = (value: string): string => + value + // Path separators, control characters and the bytes Windows shares refuse. + .replace(/[/\\<>:"|?*]/g, ' ') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +interface SignerName { + first: string; + last: string; +} + +/** "Mary Anne van Dijk" -> { first: "Mary Anne van", last: "Dijk" } */ +export function splitName(signerName: string): SignerName { + const tokens = sanitize(signerName).split(' ').filter(Boolean); + if (tokens.length === 0) return { first: '', last: 'Unknown' }; + const last = tokens[tokens.length - 1] ?? 'Unknown'; + return { first: tokens.slice(0, -1).join(' '), last }; +} + +/** A-Z from the last name; everything else is filed under "_". */ +export function letterFor(signerName: string): string { + const letter = (splitName(signerName).last[0] ?? '').toUpperCase(); + return /^[A-Z]$/.test(letter) ? letter : '_'; +} + +/** ", NDA.pdf" — no first name, no comma. */ +export function ndaFileName(signerName: string, day: string): string { + const { first, last } = splitName(signerName); + return `${first ? `${last}, ${first}` : last} ${day} NDA.pdf`; +} + +const exists = async (target: string): Promise => { + try { + await access(target); + return true; + } catch { + return false; + } +}; + +/** + * " (2)", " (3)", … before the extension — the convention every file manager + * uses, so the files stay sorted next to each other. + */ +async function freeName(dir: string, fileName: string): Promise { + const ext = path.extname(fileName); + const base = fileName.slice(0, fileName.length - ext.length); + let candidate = fileName; + for (let n = 2; await exists(path.join(dir, candidate)); n += 1) { + candidate = `${base} (${n})${ext}`; + } + return path.join(dir, candidate); +} + +/** Directory a signer's files belong in, given the buyer's active state. */ +export function ndaDirFor(signerName: string, active: boolean): string { + const letter = path.join(config.ndaRoot, letterFor(signerName)); + return active ? path.join(letter, ACTIVE_DIR) : letter; +} + +/** Writes the PDF and returns the absolute path it ended up at. */ +export async function writeNdaFile( + pdf: Buffer, + signerName: string, + day: string, + { active = true }: { active?: boolean } = {}, +): Promise { + const dir = ndaDirFor(signerName, active); + await mkdir(dir, { recursive: true }); + const target = await freeName(dir, ndaFileName(signerName, day)); + await writeFile(target, pdf); + return target; +} + +/** + * Moves one filed NDA between `/active/` and `/`. Returns the + * new path, or null when the file is already where it belongs (or lives + * somewhere else entirely — hand-filed paths are left alone). + */ +export async function moveNdaFile( + currentPath: string, + { active }: { active: boolean }, +): Promise { + const root = path.resolve(config.ndaRoot); + const resolved = path.resolve(currentPath); + if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; + + const dir = path.dirname(resolved); + const inActive = path.basename(dir) === ACTIVE_DIR; + if (inActive === active) return null; + + // Exactly one level, either way, and never out of the letter directory. + const targetDir = active ? path.join(dir, ACTIVE_DIR) : path.dirname(dir); + if (!targetDir.startsWith(root + path.sep)) return null; + await mkdir(targetDir, { recursive: true }); + const target = await freeName(targetDir, path.basename(resolved)); + await rename(resolved, target); + return target; +} + +export interface ResolvedNdaFile { + absPath: string; + size: number; + mtimeMs: number; +} + +/** + * Validates a stored nas_path before streaming it: it has to resolve to a real + * file inside realpath(NDA_ROOT), which also catches symlinks pointing out. + */ +export async function resolveNdaFile(nasPath: string | null): Promise { + if (!nasPath) throw new NdaFileError(404, 'no file is filed for this NDA'); + + let root: string; + try { + root = await realpath(config.ndaRoot); + } catch { + throw new NdaFileError(404, 'the NDA directory is not reachable'); + } + + let absPath: string; + try { + absPath = await realpath(nasPath); + } catch { + throw new NdaFileError(404, 'file not found'); + } + if (absPath !== root && !absPath.startsWith(root + path.sep)) { + throw new NdaFileError(400, 'path escapes the NDA directory'); + } + + const info = await stat(absPath); + if (!info.isFile()) throw new NdaFileError(404, 'file not found'); + return { absPath, size: info.size, mtimeMs: Math.floor(info.mtimeMs) }; +} diff --git a/src/nda-inbox-routes.ts b/src/nda-inbox-routes.ts new file mode 100644 index 0000000..4f2d43d --- /dev/null +++ b/src/nda-inbox-routes.ts @@ -0,0 +1,478 @@ +import path from 'node:path'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { query, queryOne, withTransaction } from './db.js'; +import { staffIdFromRequest } from './session.js'; +import { applySignedRule, ensureBuyerAndContact } from './buyer-routes.js'; +import { + DropboxSignError, + NDA_TITLE_PREFIX, + type SignatureRequest, + downloadFile, + getSignatureRequest, + isConfigured, + statusOf, +} from './dropbox-sign.js'; +import { + META_LAST_REFRESH, + META_STATE, + clearStaleRefreshState, + getMeta, + startRefresh, +} from './nda-refresh.js'; +import { NdaFileError, resolveNdaFile, writeNdaFile } from './nda-files.js'; +import { UUID_RE, badId, sendFile, trimmed } from './http.js'; + +/** How far back the inbox looks when the caller does not say. */ +const DEFAULT_WINDOW_DAYS = 90; + +/** Words too common in a business name to say anything about a match. */ +const STOPWORDS = new Set([ + 'the', + 'and', + 'llc', + 'inc', + 'co', + 'corp', + 'company', + 'buyer', + 'buyers', + 'forms', + 'form', + 'nda', + 'ndas', + 'of', + 'for', + 'a', +]); + +const tokenize = (value: string): string[] => + value + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 2 && !STOPWORDS.has(token)); + +/** Unix seconds -> 'YYYY-MM-DD', which is how every date column is stored. */ +const dayOf = (unixSeconds: number): string => + new Date(unixSeconds * 1000).toISOString().slice(0, 10); + +const signerOf = (request: SignatureRequest) => { + const signature = request.signatures?.[0]; + return { + name: trimmed(signature?.signer_name), + email: trimmed(signature?.signer_email_address), + signed_at: signature?.signed_at ? dayOf(signature.signed_at) : null, + }; +}; + +/** What is left of the title once the prefix and the signer's name are gone. */ +function titleRemainder(title: string, signerName: string): string { + let remainder = title.startsWith(NDA_TITLE_PREFIX) + ? title.slice(NDA_TITLE_PREFIX.length) + : title; + for (const token of signerName.split(/\s+/).filter(Boolean)) { + remainder = remainder.replace( + new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), + ' ', + ); + } + return remainder.replace(/[\s\-–—:,.]+/g, ' ').trim(); +} + +interface BusinessRow { + id: string; + name: string; + status: string; +} + +/** Plain token overlap — good enough to put the obvious candidate on top. */ +function suggestBusinesses(remainder: string, businesses: BusinessRow[]) { + const wanted = new Set(tokenize(remainder)); + if (wanted.size === 0) return []; + return businesses + .map((business) => { + const tokens = tokenize(business.name); + const hits = tokens.filter((token) => wanted.has(token)).length; + return { business, score: hits }; + }) + .filter((row) => row.score > 0) + .sort( + (a, b) => + b.score - a.score || + // A shorter name matching the same words is the more specific hit. + a.business.name.length - b.business.name.length, + ) + .slice(0, 5) + .map((row) => ({ id: row.business.id, name: row.business.name, status: row.business.status })); +} + +/** 503 rather than a confusing 500 when nobody has configured the API key. */ +function notConfigured(reply: FastifyReply): FastifyReply { + return reply + .code(503) + .send({ error: 'Dropbox Sign is not configured — set DROPBOX_SIGN_API_KEY and restart' }); +} + +function dropboxError(err: unknown, reply: FastifyReply): boolean { + if (!(err instanceof DropboxSignError)) return false; + reply.code(502).send({ error: err.message }); + return true; +} + +/** + * A decline is a dead end that no list surfaces any more, so it gets a + * flagged note on the buyer — that is the one place the team will see it. + */ +async function noteDeclined( + tx: { query: (text: string, params?: unknown[]) => Promise }, + buyerId: string, + day: string, + staffId: string, +): Promise { + await tx.query( + 'INSERT INTO note (text, highlight, buyer_id, created_by) VALUES ($1, true, $2, $3)', + [`NDA declined via Dropbox Sign, ${day}`, buyerId, staffId], + ); +} + +/** + * Files the signed PDF for one NDA. Never throws: a NAS that is full, + * read-only or unreachable must not undo an import that already succeeded, so + * the caller reports the problem as a warning instead. + */ +async function fileSignedPdf( + app: FastifyInstance, + ndaId: string, + request: SignatureRequest, +): Promise<{ nas_path: string | null; warning: string | null }> { + const signer = signerOf(request); + try { + const pdf = await downloadFile(request); + const nasPath = await writeNdaFile( + pdf, + signer.name || 'Unknown', + signer.signed_at ?? dayOf(request.created_at), + ); + await query('UPDATE nda SET nas_path = $1 WHERE id = $2', [nasPath, ndaId]); + return { nas_path: nasPath, warning: null }; + } catch (err) { + const message = (err as Error).message; + app.log.warn(`[nda-inbox] filing the PDF for ${ndaId} failed: ${message}`); + return { nas_path: null, warning: `the NDA was imported, but the PDF could not be filed: ${message}` }; + } +} + +/** + * The NDA inbox: what Dropbox Sign has, what of it is already in the database, + * and the one-click import that turns a signature request into buyer + + * contact + NDA round (+ deal). + */ +export function registerNdaInboxRoutes(app: FastifyInstance): void { + // The task lives in this process, so a "running" left in app_meta can only + // be the remains of a kill. + app.addHook('onReady', () => clearStaleRefreshState(app.log)); + + /** + * Reads ds_request only — never Dropbox. Mounting the view is one indexed + * query, so switching tabs costs nothing and the list is always there. + */ + app.get<{ Querystring: { since?: string } }>('/api/nda-inbox', async (req, reply) => { + if (!isConfigured()) return notConfigured(reply); + + const sinceParam = trimmed(req.query.since); + const since = sinceParam + ? new Date(sinceParam) + : new Date(Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000); + if (Number.isNaN(since.getTime())) { + return reply.code(400).send({ error: `invalid since: ${sinceParam}` }); + } + + // Full timestamps, not calendar days: Dropbox orders strictly by date and + // time, and dropping the time made same-day rows look arbitrarily ordered. + const rows = await query<{ + signature_request_id: string; + title: string | null; + created_at: Date | null; + status: string; + signer_name: string | null; + signer_email: string | null; + /** Also a full timestamp — Dropbox reports the second it was signed. */ + signed_at: Date | null; + nda_id: string | null; + buyer_id: string | null; + }>( + // timestamptz is left to the driver: it comes back as a Date and + // serialises to a full ISO-8601 string, offset and all. + `SELECT r.signature_request_id, r.title, r.status, r.signer_name, r.signer_email, + r.created_at, r.signed_at, + n.id AS nda_id, n.buyer_id + FROM ds_request r + LEFT JOIN nda n ON n.dropbox_sign_id = r.signature_request_id + WHERE r.created_at >= $1 + -- Newest first, to the second, exactly as Dropbox lists them. The id + -- only breaks ties so the order is deterministic across requests. + ORDER BY r.created_at DESC, r.signature_request_id DESC`, + [since.toISOString()], + ); + + const emails = [ + ...new Set(rows.map((row) => trimmed(row.signer_email).toLowerCase()).filter(Boolean)), + ]; + // Same normalised-e-mail rule the duplicate check uses, exact matches only. + const known = await query<{ + email: string; + buyer_id: string; + company_name: string | null; + contact_name: string; + }>( + `SELECT lower(btrim(c.email)) AS email, c.buyer_id, b.company_name, c.name AS contact_name + FROM contact c JOIN buyer b ON b.id = c.buyer_id + WHERE lower(btrim(c.email)) = ANY($1) + ORDER BY c.is_primary DESC, c.created_at`, + [emails], + ); + const businesses = await query('SELECT id, name, status FROM business'); + + const requests = rows.map((row) => { + const remainder = titleRemainder(row.title ?? '', row.signer_name ?? ''); + const email = trimmed(row.signer_email); + const match = email ? known.find((k) => k.email === email.toLowerCase()) : undefined; + return { + signature_request_id: row.signature_request_id, + title: row.title ?? '', + title_remainder: remainder, + created_at: row.created_at, + status: row.status, + signer: { name: row.signer_name ?? '', email }, + signed_at: row.signed_at, + imported: row.nda_id ? { nda_id: row.nda_id, buyer_id: row.buyer_id } : null, + known_buyer: match + ? { + buyer_id: match.buyer_id, + company_name: match.company_name, + contact_name: match.contact_name, + } + : null, + business_suggestions: suggestBusinesses(remainder, businesses), + }; + }); + + return { + requests, + last_refresh_at: await getMeta(META_LAST_REFRESH), + refresh_state: (await getMeta(META_STATE)) ?? 'idle', + }; + }); + + /** + * Starts the background walk. 202 when it was started, 409 when one is + * already running — the UI polls the state out of GET /api/nda-inbox. + */ + app.post<{ Querystring: { since?: string } }>('/api/nda-inbox/refresh', async (req, reply) => { + if (!isConfigured()) return notConfigured(reply); + + const sinceParam = trimmed(req.query.since); + const since = sinceParam + ? new Date(sinceParam) + : new Date(Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000); + if (Number.isNaN(since.getTime())) { + return reply.code(400).send({ error: `invalid since: ${sinceParam}` }); + } + + if (!(await startRefresh(app.log, since))) { + return reply.code(409).send({ error: 'a refresh is already running', state: 'running' }); + } + return reply.code(202).send({ state: 'running', since: since.toISOString() }); + }); + + app.post<{ Body: { signature_request_id?: string; buyer_id?: string; business_id?: string } }>( + '/api/nda-inbox/import', + async (req, reply) => { + if (!isConfigured()) return notConfigured(reply); + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + const requestId = trimmed(req.body?.signature_request_id); + if (!requestId) { + return reply.code(400).send({ error: 'signature_request_id is missing' }); + } + const buyerId = trimmed(req.body?.buyer_id) || null; + const businessId = trimmed(req.body?.business_id) || null; + + const already = await queryOne<{ id: string; buyer_id: string }>( + 'SELECT id, buyer_id FROM nda WHERE dropbox_sign_id = $1', + [requestId], + ); + if (already) { + return reply + .code(409) + .send({ error: 'this signature request has already been imported', ...already }); + } + if (buyerId) { + if (!UUID_RE.test(buyerId)) return reply.code(404).send({ error: 'buyer not found' }); + if (!(await queryOne('SELECT id FROM buyer WHERE id = $1', [buyerId]))) { + return reply.code(404).send({ error: 'buyer not found' }); + } + } + if (businessId) { + if (!UUID_RE.test(businessId)) return reply.code(404).send({ error: 'business not found' }); + if (!(await queryOne('SELECT id FROM business WHERE id = $1', [businessId]))) { + return reply.code(404).send({ error: 'business not found' }); + } + } + + let request: SignatureRequest; + try { + request = await getSignatureRequest(requestId); + } catch (err) { + if (dropboxError(err, reply)) return reply; + throw err; + } + const signer = signerOf(request); + if (!signer.name && !signer.email) { + return reply.code(400).send({ error: 'the signature request carries no signer' }); + } + const status = statusOf(request); + const signed = status === 'signed'; + + const result = await withTransaction(async (tx) => { + const { buyerId: targetBuyer, createdBuyer, createdContact } = await ensureBuyerAndContact( + tx, + { + buyerId, + companyName: null, + contact: { name: signer.name || signer.email, email: signer.email }, + staffId, + }, + ); + + // A declined round was never signed, so it stays SENT and is only + // flagged — the team still wants to see that it came back. + const nda = await tx.queryRow<{ id: string }>( + `INSERT INTO nda (buyer_id, status, sent_at, signed_at, dropbox_sign_id, + signer_name, declined, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + [ + targetBuyer, + signed ? 'SIGNED' : 'SENT', + dayOf(request.created_at), + signed ? signer.signed_at : null, + request.signature_request_id, + signer.name || null, + status === 'declined', + staffId, + ], + ); + await applySignedRule(tx, nda.id); + + if (status === 'declined') { + await noteDeclined(tx, targetBuyer, dayOf(request.created_at), staffId); + } + + const deal = businessId + ? await tx.queryRow<{ id: string }>( + `INSERT INTO deal (buyer_id, business_id, nda_id, created_by) + VALUES ($1, $2, $3, $4) RETURNING id`, + [targetBuyer, businessId, nda.id, staffId], + ) + : null; + + return { + buyer_id: targetBuyer, + nda_id: nda.id, + deal_id: deal?.id ?? null, + created: { buyer: createdBuyer, contact: createdContact }, + }; + }); + + // Outside the transaction on purpose: the import is already durable. + const filed = signed + ? await fileSignedPdf(app, result.nda_id, request) + : { nas_path: null, warning: null }; + + return reply.code(201).send({ ...result, status, ...filed }); + }, + ); + + /** + * Catches up with whatever happened on the Dropbox side since the import: + * signatures that came in and requests that were declined. + */ + app.post('/api/nda-inbox/sync', async (req, reply) => { + if (!isConfigured()) return notConfigured(reply); + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + // Declined rounds are excluded: they stay SENT forever but will never + // change state on the Dropbox side again, so re-fetching them is waste. + const pending = await query<{ id: string; dropbox_sign_id: string }>( + `SELECT id, dropbox_sign_id FROM nda + WHERE dropbox_sign_id IS NOT NULL AND status = 'SENT' AND NOT declined + ORDER BY sent_at`, + ); + + let signed = 0; + let declined = 0; + let failed = 0; + const warnings: string[] = []; + + for (const nda of pending) { + try { + const request = await getSignatureRequest(nda.dropbox_sign_id); + const status = statusOf(request); + if (status === 'signed') { + const signer = signerOf(request); + await withTransaction(async (tx) => { + await tx.query(`UPDATE nda SET status = 'SIGNED', signed_at = $2 WHERE id = $1`, [ + nda.id, + signer.signed_at, + ]); + await applySignedRule(tx, nda.id); + }); + const filed = await fileSignedPdf(app, nda.id, request); + if (filed.warning) warnings.push(filed.warning); + signed += 1; + } else if (status === 'declined') { + await withTransaction(async (tx) => { + const flipped = await tx.queryRow<{ buyer_id: string; sent_at: string | null }>( + 'UPDATE nda SET declined = true WHERE id = $1 RETURNING buyer_id, sent_at', + [nda.id], + ); + await noteDeclined( + tx, + flipped.buyer_id, + flipped.sent_at ?? dayOf(request.created_at), + staffId, + ); + }); + declined += 1; + } + } catch (err) { + app.log.warn(`[nda-inbox] sync of ${nda.id} failed: ${(err as Error).message}`); + warnings.push(`${nda.dropbox_sign_id}: ${(err as Error).message}`); + failed += 1; + } + } + + return { checked: pending.length, signed, declined, failed, warnings }; + }); + + /** The filed PDF of one round, with the same mechanics as business files. */ + app.get<{ Params: { id: string } }>('/api/ndas/:id/file', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'nda')) return reply; + + const nda = await queryOne<{ nas_path: string | null }>( + 'SELECT nas_path FROM nda WHERE id = $1', + [id], + ); + if (!nda) return reply.code(404).send({ error: 'nda not found' }); + + try { + const file = await resolveNdaFile(nda.nas_path); + return sendFile(req, reply, { ...file, name: path.basename(file.absPath) }); + } catch (err) { + if (err instanceof NdaFileError) return reply.code(err.status).send({ error: err.message }); + throw err; + } + }); +} diff --git a/src/nda-refresh.ts b/src/nda-refresh.ts new file mode 100644 index 0000000..d1a8fe4 --- /dev/null +++ b/src/nda-refresh.ts @@ -0,0 +1,198 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { query, queryOne, withTransaction } from './db.js'; +import { type SignatureRequest, isRateLimited, listPage, statusOf } from './dropbox-sign.js'; + +/** + * Mirrors the Dropbox Sign signature requests into ds_request in the + * background, so the inbox view can read them straight out of the database. + * + * The list endpoint is the only way to enumerate requests, and with 700+ of + * them a window walk is 7-8 calls and well over a minute — far too slow to sit + * in front of a view mount, and enough to get throttled. So it runs detached, + * paced, and at most once at a time. + */ + +/** Between two list calls, so a full walk does not look like a hammer. */ +const PAGE_PAUSE_MS = 500; +/** Even when Dropbox asks for less, back off at least this long. */ +const MIN_BACKOFF_MS = 10_000; +const MAX_PAGE_ATTEMPTS = 3; +/** Safety net against a window that never reaches its cutoff. */ +const MAX_PAGES = 50; + +export const META_LAST_REFRESH = 'ds_last_refresh_at'; +export const META_STATE = 'ds_refresh_state'; + +export type RefreshState = 'idle' | 'running' | `error:${string}`; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function getMeta(key: string): Promise { + const row = await queryOne<{ value: string | null }>( + 'SELECT value FROM app_meta WHERE key = $1', + [key], + ); + return row?.value ?? null; +} + +export async function setMeta(key: string, value: string): Promise { + await query( + `INSERT INTO app_meta (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + [key, value], + ); +} + +/** + * Claims the running slot atomically — two clicks on Refresh must not start + * two walks. The conditional upsert is the lock; it returns nothing when + * somebody else already holds it. + */ +async function claimRefreshSlot(): Promise { + const claimed = await queryOne<{ key: string }>( + `INSERT INTO app_meta (key, value) VALUES ($1, 'running') + ON CONFLICT (key) DO UPDATE SET value = 'running', updated_at = now() + WHERE app_meta.value <> 'running' + RETURNING key`, + [META_STATE], + ); + return Boolean(claimed); +} + +/** + * The task is in-process, so a state of "running" at startup can only be the + * remains of a killed process. + */ +export async function clearStaleRefreshState(log: FastifyBaseLogger): Promise { + const stale = await queryOne<{ key: string }>( + `UPDATE app_meta SET value = 'idle', updated_at = now() + WHERE key = $1 AND value = 'running' RETURNING key`, + [META_STATE], + ); + if (stale) log.warn('[nda-refresh] found a stale "running" state at startup, reset to idle'); +} + +async function upsertPage(requests: SignatureRequest[]): Promise { + if (requests.length === 0) return; + // One transaction per page: a page either lands whole or not at all. + await withTransaction(async (tx) => { + for (const request of requests) { + const signature = request.signatures?.[0]; + await tx.query( + `INSERT INTO ds_request (signature_request_id, title, created_at, status, + signer_name, signer_email, signed_at, fetched_at) + VALUES ($1, $2, to_timestamp($3), $4, $5, $6, + CASE WHEN $7::bigint IS NULL THEN NULL ELSE to_timestamp($7::bigint) END, now()) + ON CONFLICT (signature_request_id) DO UPDATE SET + title = EXCLUDED.title, + created_at = EXCLUDED.created_at, + -- The point of re-walking: a row that was pending last time may + -- have been signed or declined since. + status = EXCLUDED.status, + signer_name = EXCLUDED.signer_name, + signer_email = EXCLUDED.signer_email, + signed_at = EXCLUDED.signed_at, + fetched_at = now()`, + [ + request.signature_request_id, + request.title ?? null, + request.created_at, + statusOf(request), + signature?.signer_name ?? null, + signature?.signer_email_address ?? null, + signature?.signed_at ?? null, + ], + ); + } + }); +} + +export interface RefreshResult { + pages: number; + stored: number; + seen: number; +} + +/** Fetches one page, backing off and retrying while Dropbox throttles us. */ +async function fetchPageWithBackoff( + page: number, + log: FastifyBaseLogger, + loggedBody: { done: boolean }, +) { + for (let attempt = 1; ; attempt += 1) { + try { + return await listPage(page, 100); + } catch (err) { + if (!isRateLimited(err) || attempt >= MAX_PAGE_ATTEMPTS) throw err; + // Log the body once per run: we still do not know what Dropbox means by + // the 409 it sometimes answers instead of a 429. + if (!loggedBody.done) { + loggedBody.done = true; + log.warn( + `[nda-refresh] throttled with ${err.status} on page ${page}; body: ${err.body ?? ''}`, + ); + } + const wait = Math.max(MIN_BACKOFF_MS, (err.retryAfterSeconds ?? 0) * 1000); + log.warn( + `[nda-refresh] page ${page} attempt ${attempt}/${MAX_PAGE_ATTEMPTS} failed with ` + + `${err.status}, waiting ${Math.round(wait / 1000)}s`, + ); + await sleep(wait); + } + } +} + +/** + * Walks the list newest-first and upserts every NDA request created on or + * after `since`, stopping at the first page whose oldest entry is older than + * that. Throws only for the caller to record — the route has long returned. + */ +async function walk(since: Date, log: FastifyBaseLogger): Promise { + const cutoff = Math.floor(since.getTime() / 1000); + const loggedBody = { done: false }; + let stored = 0; + let seen = 0; + let page = 1; + + for (; page <= MAX_PAGES; page += 1) { + if (page > 1) await sleep(PAGE_PAUSE_MS); + const result = await fetchPageWithBackoff(page, log, loggedBody); + if (result.requests.length === 0) break; + seen += result.requests.length; + + const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff); + await upsertPage(inWindow); + stored += inWindow.length; + + // Newest first, so the last entry is the oldest one on this page. + const oldest = result.requests[result.requests.length - 1]?.created_at ?? 0; + if (oldest < cutoff) break; + if (result.page >= result.numPages) break; + } + + log.info(`[nda-refresh] walked ${page} page(s), saw ${seen}, stored ${stored} NDA request(s)`); + return { pages: page, stored, seen }; +} + +/** + * Starts the walk detached and returns immediately. `false` means somebody + * else is already refreshing. + */ +export async function startRefresh(log: FastifyBaseLogger, since: Date): Promise { + if (!(await claimRefreshSlot())) return false; + + void (async () => { + try { + await walk(since, log); + await setMeta(META_LAST_REFRESH, new Date().toISOString()); + await setMeta(META_STATE, 'idle'); + } catch (err) { + const message = (err as Error).message; + log.error(`[nda-refresh] refresh failed: ${message}`); + // Kept in the state so the UI can show why, rather than a silent idle. + await setMeta(META_STATE, `error:${message}`.slice(0, 400)).catch(() => {}); + } + })(); + + return true; +} diff --git a/src/server.ts b/src/server.ts index bdffa49..ba32d26 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { createReadStream, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; @@ -15,6 +15,8 @@ import { } from './business-scan.js'; import { registerBuyerRoutes } from './buyer-routes.js'; import { registerWorkflowRoutes } from './workflow-routes.js'; +import { registerNdaInboxRoutes } from './nda-inbox-routes.js'; +import { sendFile } from './http.js'; import { COOKIE, staffIdFromRequest } from './session.js'; interface Staff { @@ -171,33 +173,6 @@ app.get<{ Params: { id: string } }>('/api/businesses/:id/files', async (req, rep } }); -/** Parses a single-range `Range` header. null = ignore, 'invalid' = 416. */ -function parseRange( - header: string | undefined, - size: number, -): { start: number; end: number } | null | 'invalid' { - if (!header) return null; - const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); - if (!match) return null; // multi-range or garbage: answer with the full body - const [, rawStart, rawEnd] = match; - if (rawStart === '' && rawEnd === '') return 'invalid'; - - let start: number; - let end: number; - if (rawStart === '') { - // suffix range: the last N bytes - const suffix = Number(rawEnd); - if (suffix === 0) return 'invalid'; - start = Math.max(0, size - suffix); - end = size - 1; - } else { - start = Number(rawStart); - end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1); - } - if (start > end || start >= size) return 'invalid'; - return { start, end }; -} - app.get<{ Params: { id: string }; Querystring: { path?: string } }>( '/api/businesses/:id/file', async (req, reply) => { @@ -214,37 +189,8 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>( throw err; } - const etag = `"${file.mtimeMs.toString(16)}-${file.size.toString(16)}"`; - reply.header('ETag', etag); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - - const ifNoneMatch = req.headers['if-none-match']; - if (ifNoneMatch && ifNoneMatch.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) { - return reply.code(304).send(); - } - - const range = parseRange(req.headers.range, file.size); - if (range === 'invalid') { - reply.header('Content-Range', `bytes */${file.size}`); - return reply.code(416).send({ error: 'range not satisfiable' }); - } - const name = (req.query.path ?? '').split('/').pop() ?? 'file'; - const isPdf = name.toLowerCase().endsWith('.pdf'); - const filenameStar = `filename*=UTF-8''${encodeURIComponent(name)}`; - reply.header('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); - reply.header('Content-Disposition', `${isPdf ? 'inline' : 'attachment'}; ${filenameStar}`); - - if (range) { - reply.code(206); - reply.header('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`); - reply.header('Content-Length', range.end - range.start + 1); - return reply.send(createReadStream(file.absPath, { start: range.start, end: range.end })); - } - - reply.header('Content-Length', file.size); - return reply.send(createReadStream(file.absPath)); + return sendFile(req, reply, { ...file, name }); }, ); @@ -255,6 +201,9 @@ registerBuyerRoutes(app); // ------------------------------------------- Notes / todos / the Today view registerWorkflowRoutes(app); +// ------------------------------------------ NDA inbox (Dropbox Sign) + PDFs +registerNdaInboxRoutes(app); + // --------------------------------------------------------------- Static // Serves the built frontend in production; SPA fallback for non-/api routes. const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist'); diff --git a/src/workflow-routes.ts b/src/workflow-routes.ts index 671a3ff..80dde16 100644 --- a/src/workflow-routes.ts +++ b/src/workflow-routes.ts @@ -494,7 +494,9 @@ export function registerWorkflowRoutes(app: FastifyInstance): void { FROM nda n JOIN buyer b ON b.id = n.buyer_id LEFT JOIN staff s ON s.id = n.created_by - WHERE n.status = 'SENT' AND n.sent_at <= CURRENT_DATE - $2::int + -- Declined rounds stay SENT but nobody is waiting for that signature. + WHERE n.status = 'SENT' AND NOT n.declined + AND n.sent_at <= CURRENT_DATE - $2::int AND ($1::uuid IS NULL OR n.created_by = $1) ORDER BY n.sent_at`, [staffId, config.ndaReminderDays], diff --git a/web/src/App.tsx b/web/src/App.tsx index 484e531..f1f9381 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { ApiError, api, type Staff } from './api.js'; import Login from './views/Login.js'; import Today from './views/Today.js'; +import NdaInbox from './views/NdaInbox.js'; import Businesses from './views/Businesses.js'; import BusinessDetail from './views/BusinessDetail.js'; import Buyers from './views/Buyers.js'; @@ -11,6 +12,7 @@ import NewInquiry from './views/NewInquiry.js'; /** Hand-rolled routing: which view, and (for the detail views) which row. */ type Route = | { view: 'today' } + | { view: 'nda-inbox' } | { view: 'businesses' } | { view: 'business'; id: string } | { view: 'buyers' } @@ -19,6 +21,7 @@ type Route = const NAV: { label: string; route: Route; active: Route['view'][] }[] = [ { label: 'Today', route: { view: 'today' }, active: ['today'] }, + { label: 'NDA Inbox', route: { view: 'nda-inbox' }, active: ['nda-inbox'] }, { label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] }, { label: 'Buyers', route: { view: 'buyers' }, active: ['buyers', 'buyer', 'new-inquiry'] }, ]; @@ -108,7 +111,12 @@ export default function App() { /> ) : ( -
+
{route.view === 'today' && ( )} + {route.view === 'nda-inbox' && ( + setRoute({ view: 'buyer', id })} + onChanged={refreshBadge} + /> + )} {route.view === 'businesses' && ( setRoute({ view: 'business', id })} /> )} diff --git a/web/src/api.ts b/web/src/api.ts index dcd7241..fcca045 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -105,6 +105,11 @@ export interface NdaRound { preferred_businesses_text: string | null; total_purchase_price: string | null; down_payment: string | null; + /** Set when the round came in through the NDA inbox. */ + dropbox_sign_id: string | null; + signer_name: string | null; + /** A declined round stays SENT — it was never signed. */ + declined: boolean; deals: Deal[]; } @@ -250,6 +255,53 @@ export interface Today { counts: TodayCounts; } +// ------------------------------------------------------------- NDA inbox +export type InboxStatus = 'pending' | 'signed' | 'declined'; + +export interface InboxRow { + signature_request_id: string; + title: string; + /** The title with the prefix and the signer's name stripped out. */ + title_remainder: string; + /** Full ISO timestamp: Dropbox orders by date *and* time. */ + created_at: string; + status: InboxStatus; + signer: { name: string; email: string }; + /** Full ISO timestamp as well — the second the signature came in. */ + signed_at: string | null; + imported: { nda_id: string; buyer_id: string } | null; + known_buyer: { buyer_id: string; company_name: string | null; contact_name: string } | null; + business_suggestions: { id: string; name: string; status: BusinessStatus }[]; +} + +export type RefreshState = 'idle' | 'running' | string; + +export interface Inbox { + requests: InboxRow[]; + /** ISO timestamp of the last completed background refresh, null if never. */ + last_refresh_at: string | null; + refresh_state: RefreshState; +} + +export interface ImportResult { + buyer_id: string; + nda_id: string; + deal_id: string | null; + created: { buyer: boolean; contact: boolean }; + status: InboxStatus; + nas_path: string | null; + /** Set when the round was imported but the PDF could not be filed. */ + warning: string | null; +} + +export interface SyncResult { + checked: number; + signed: number; + declined: number; + failed: number; + warnings: string[]; +} + export class ApiError extends Error { constructor(readonly status: number, message: string) { super(message); @@ -352,14 +404,37 @@ export const api = { business_id: businessId, path: filePath, }), + + /** Reads the mirrored requests out of the database — never calls Dropbox. */ + ndaInbox: (since: string) => request(`/api/nda-inbox?${qs({ since })}`), + /** Starts the background walk; resolves as soon as it is queued. */ + refreshInbox: (since: string) => + post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`), + importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) => + post('/api/nda-inbox/import', { + signature_request_id: signatureRequestId, + buyer_id: buyerId, + business_id: businessId, + }), + syncInbox: () => post('/api/nda-inbox/sync'), }; +/** Same-origin streaming URL of the PDF filed for one NDA round. */ +export function ndaFileUrl(ndaId: string): string { + return `/api/ndas/${ndaId}/file`; +} + /** Same-origin streaming URL of one file inside a business directory. */ export function businessFileUrl(id: string, filePath: string): string { return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`; } -/** The standalone (unbundled) pdf.js viewer page, pointed at a business file. */ -export function viewerUrl(id: string, filePath: string): string { - return `/viewer/index.html?file=${encodeURIComponent(businessFileUrl(id, filePath))}`; +/** The standalone (unbundled) pdf.js viewer page, pointed at any /api/ file URL. */ +export function viewerUrlFor(apiPath: string): string { + return `/viewer/index.html?file=${encodeURIComponent(apiPath)}`; +} + +/** The viewer, pointed at a business file. */ +export function viewerUrl(id: string, filePath: string): string { + return viewerUrlFor(businessFileUrl(id, filePath)); } diff --git a/web/src/components.tsx b/web/src/components.tsx index f5ac6ee..b701b00 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -20,6 +20,43 @@ export function formatDay(day: Day | null | undefined): string { }); } +/** + * An instant split into its two halves, both in local time: "Jul 27, 2026" + * and "3:02 PM". Kept apart so a narrow column can wrap the time under the + * date instead of truncating it. + */ +export function formatDayTimeParts(iso: string | null): { day: string; time: string } { + if (!iso) return { day: '—', time: '' }; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return { day: iso, time: '' }; + return { + day: at.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), + time: at.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }), + }; +} + +/** "just now" / "12 minutes ago" / "3 days ago" — for the refresh line. */ +export function formatRelative(iso: string | null): string { + if (!iso) return 'never'; + const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const steps: [number, string][] = [ + [60, 'minute'], + [3600, 'hour'], + [86400, 'day'], + ]; + let unit = 'minute'; + let size = 60; + for (const [step, name] of steps) { + if (seconds >= step) { + size = step; + unit = name; + } + } + const value = Math.floor(seconds / size); + return `${value} ${unit}${value === 1 ? '' : 's'} ago`; +} + export function formatStamp(iso: string): string { return new Date(iso).toLocaleString('en-US', { month: 'short', diff --git a/web/src/views/BuyerDetail.tsx b/web/src/views/BuyerDetail.tsx index 92ffc78..e725630 100644 --- a/web/src/views/BuyerDetail.tsx +++ b/web/src/views/BuyerDetail.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; import { api, + ndaFileUrl, + viewerUrlFor, type Buyer, type BusinessListItem, type Contact, @@ -375,6 +377,26 @@ function Round({ )} sent {formatDay(nda.sent_at)} + {/* A declined round keeps status SENT, so it needs its own badge. */} + {nda.declined && ( + + Declined + + )} + {nda.signer_name && ( + signed by {nda.signer_name} + )} +
+ {nda.nas_path && ( + + View PDF + + )}
diff --git a/web/src/views/NdaInbox.tsx b/web/src/views/NdaInbox.tsx new file mode 100644 index 0000000..fc73de1 --- /dev/null +++ b/web/src/views/NdaInbox.tsx @@ -0,0 +1,313 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, type Inbox, type InboxRow, type InboxStatus, type SyncResult } from '../api.js'; +import { formatDayTimeParts, formatRelative } from '../components.js'; + +const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm'; + +const STATUS_TONE: Record = { + pending: 'border-amber-300 bg-amber-50 text-amber-800', + signed: 'border-green-300 bg-green-50 text-green-800', + declined: 'border-red-300 bg-red-50 text-red-800', +}; + +const isoDaysAgo = (days: number) => + new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); + +/** + * Date and time as two nowrap spans: they sit on one line when the column + * allows it, otherwise the time wraps under the date. The time matters — + * Dropbox orders to the second, and same-day rows are common. + */ +function DateTime({ iso }: { iso: string }) { + const { day, time } = formatDayTimeParts(iso); + return ( + <> + {day}{' '} + {time} + + ); +} + +export default function NdaInbox({ + onOpenBuyer, + onChanged, +}: { + onOpenBuyer: (buyerId: string) => void; + /** Imported rounds land in Today's pending list, so the badge follows. */ + onChanged: () => void; +}) { + const [since, setSince] = useState(() => isoDaysAgo(90)); + const [inbox, setInbox] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + // Business chosen per row; '' means "import the NDA without a deal". + const [picked, setPicked] = useState>({}); + const [busy, setBusy] = useState(null); + const [notice, setNotice] = useState(null); + const poll = useRef(null); + + const rows: InboxRow[] | null = inbox?.requests ?? null; + const refreshing = inbox?.refresh_state === 'running'; + const refreshError = inbox?.refresh_state?.startsWith('error:') + ? inbox.refresh_state.slice('error:'.length) + : null; + + /** Reads the mirror out of the database — this never talks to Dropbox. */ + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.ndaInbox(since); + setInbox(res); + setError(null); + // Preselect the best suggestion, but never overwrite a manual choice. + setPicked((current) => { + const next = { ...current }; + for (const row of res.requests) { + if (next[row.signature_request_id] === undefined) { + next[row.signature_request_id] = row.business_suggestions[0]?.id ?? ''; + } + } + return next; + }); + return res; + } catch (err) { + setError((err as Error).message); + return null; + } finally { + setLoading(false); + } + }, [since]); + + useEffect(() => { + void load(); + }, [load]); + + // Whenever a walk is in flight — started here or elsewhere — watch it until + // it finishes, then show whatever it brought in. + useEffect(() => { + if (!refreshing) { + if (poll.current !== null) { + window.clearInterval(poll.current); + poll.current = null; + } + return; + } + if (poll.current !== null) return; + poll.current = window.setInterval(() => void load(), 3000); + return () => { + if (poll.current !== null) { + window.clearInterval(poll.current); + poll.current = null; + } + }; + }, [refreshing, load]); + + async function refresh() { + setError(null); + try { + await api.refreshInbox(since); + // Flip to "running" at once so the poller starts without a round trip. + setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current)); + } catch (err) { + setError((err as Error).message); + } + } + + async function sync() { + setSyncing(true); + setSyncResult(null); + setError(null); + try { + setSyncResult(await api.syncInbox()); + await load(); + onChanged(); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + } + + async function importRow(row: InboxRow) { + setBusy(row.signature_request_id); + setNotice(null); + setError(null); + try { + const res = await api.importInbox( + row.signature_request_id, + row.known_buyer?.buyer_id, + picked[row.signature_request_id] || undefined, + ); + setNotice( + res.warning ?? + `Imported ${row.signer.name || row.signer.email}` + + `${res.created.buyer ? ' as a new buyer' : ' into the existing buyer'}` + + `${res.nas_path ? ' · PDF filed' : ''}.`, + ); + await load(); + onChanged(); + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(null); + } + } + + return ( +
+
+
+ + + Last refreshed: {formatRelative(inbox?.last_refresh_at ?? null)} + {loading && !refreshing && ' · loading…'} + +
+
+ + +
+
+ + {refreshing && ( +

+ Fetching from Dropbox Sign in the background — the table below stays usable. +

+ )} + {refreshError && ( +

Last refresh failed: {refreshError}

+ )} + + {syncResult && ( +

+ Checked {syncResult.checked} · newly signed {syncResult.signed} · declined{' '} + {syncResult.declined} · failed {syncResult.failed} + {syncResult.warnings.length > 0 && ( + · {syncResult.warnings.join('; ')} + )} +

+ )} + {notice &&

{notice}

} + {error &&

{error}

} + + + + + + + + + + + + + + + {rows?.map((row) => ( + + + + + + + + + + + ))} + {rows && rows.length === 0 && ( + + + + )} + +
DateSignerE-mailStatusTitleBuyerDeal +
+ + {row.signer.name || '—'}{row.signer.email || '—'} + + {row.status} + + {row.title_remainder || '—'} + {row.known_buyer ? ( + + ) : ( + new + )} + + {row.imported ? ( + + ) : ( + + )} + + {row.imported ? ( + + ) : ( + + )} +
+ {inbox?.last_refresh_at + ? 'No NDA signature requests in this period.' + : 'Nothing fetched yet — press Refresh to load the signature requests.'} +
+
+ ); +}