module 6a
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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}
|
||||
|
||||
11
migrations/005_nda_dropbox_sign.sql
Normal file
11
migrations/005_nda_dropbox_sign.sql
Normal file
@@ -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;
|
||||
30
migrations/006_ds_request_cache.sql
Normal file
30
migrations/006_ds_request_cache.sql
Normal file
@@ -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:<msg>).
|
||||
CREATE TABLE app_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -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<void> {
|
||||
export async function applySignedRule(tx: Tx, ndaId: string): Promise<void> {
|
||||
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<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 <letter>/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],
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
157
src/dropbox-sign.ts
Normal file
157
src/dropbox-sign.ts
Normal file
@@ -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<Response> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
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<SignatureRequestPage> {
|
||||
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<SignatureRequest> {
|
||||
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<Buffer> {
|
||||
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';
|
||||
}
|
||||
74
src/http.ts
74
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<string, unknown>): Refs {
|
||||
const present = REF_COLUMNS.filter((column) => trimmed(source[column]) !== '');
|
||||
if (present.length > 1) {
|
||||
|
||||
161
src/nda-files.ts
Normal file
161
src/nda-files.ts
Normal file
@@ -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/<letter>/active/<Last>, <First> <YYYY-MM-DD> NDA.pdf while
|
||||
* the buyer is active, and one level up in NDA_ROOT/<letter>/ 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 : '_';
|
||||
}
|
||||
|
||||
/** "<Last>, <First> <YYYY-MM-DD> 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<boolean> => {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 `<letter>/active/` and `<letter>/`. 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<string | null> {
|
||||
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<ResolvedNdaFile> {
|
||||
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) };
|
||||
}
|
||||
478
src/nda-inbox-routes.ts
Normal file
478
src/nda-inbox-routes.ts
Normal file
@@ -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<unknown> },
|
||||
buyerId: string,
|
||||
day: string,
|
||||
staffId: string,
|
||||
): Promise<void> {
|
||||
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<BusinessRow>('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;
|
||||
}
|
||||
});
|
||||
}
|
||||
198
src/nda-refresh.ts
Normal file
198
src/nda-refresh.ts
Normal file
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 ?? '<empty>'}`,
|
||||
);
|
||||
}
|
||||
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<RefreshResult> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
|
||||
<main
|
||||
// The inbox table carries eight columns and needs the extra width.
|
||||
className={`mx-auto w-full flex-1 overflow-auto px-6 py-6 ${
|
||||
route.view === 'nda-inbox' ? 'max-w-7xl' : 'max-w-5xl'
|
||||
}`}
|
||||
>
|
||||
{route.view === 'today' && (
|
||||
<Today
|
||||
staff={staff}
|
||||
@@ -117,6 +125,12 @@ export default function App() {
|
||||
onChanged={refreshBadge}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'nda-inbox' && (
|
||||
<NdaInbox
|
||||
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
|
||||
onChanged={refreshBadge}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'businesses' && (
|
||||
<Businesses onOpen={(id) => setRoute({ view: 'business', id })} />
|
||||
)}
|
||||
|
||||
@@ -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<Inbox>(`/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<ImportResult>('/api/nda-inbox/import', {
|
||||
signature_request_id: signatureRequestId,
|
||||
buyer_id: buyerId,
|
||||
business_id: businessId,
|
||||
}),
|
||||
syncInbox: () => post<SyncResult>('/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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">sent {formatDay(nda.sent_at)}</span>
|
||||
{/* A declined round keeps status SENT, so it needs its own badge. */}
|
||||
{nda.declined && (
|
||||
<span className="rounded-full border border-red-300 bg-red-50 px-2 py-0.5 text-xs text-red-800">
|
||||
Declined
|
||||
</span>
|
||||
)}
|
||||
{nda.signer_name && (
|
||||
<span className="text-xs text-gray-500">signed by {nda.signer_name}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{nda.nas_path && (
|
||||
<a
|
||||
href={viewerUrlFor(ndaFileUrl(nda.id))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
|
||||
>
|
||||
View PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-3 py-3 md:grid-cols-4">
|
||||
|
||||
313
web/src/views/NdaInbox.tsx
Normal file
313
web/src/views/NdaInbox.tsx
Normal file
@@ -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<InboxStatus, string> = {
|
||||
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 (
|
||||
<>
|
||||
<span className="whitespace-nowrap">{day}</span>{' '}
|
||||
<span className="whitespace-nowrap text-gray-400">{time}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<Inbox | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
|
||||
// Business chosen per row; '' means "import the NDA without a deal".
|
||||
const [picked, setPicked] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const poll = useRef<number | null>(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 (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
Since
|
||||
<input
|
||||
type="date"
|
||||
value={since}
|
||||
onChange={(e) => setSince(e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-gray-500">
|
||||
Last refreshed: {formatRelative(inbox?.last_refresh_at ?? null)}
|
||||
{loading && !refreshing && ' · loading…'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
title="Fetches the signature requests from Dropbox Sign in the background"
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={sync}
|
||||
disabled={syncing}
|
||||
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{syncing ? 'Syncing…' : 'Sync signatures'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{refreshing && (
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
Fetching from Dropbox Sign in the background — the table below stays usable.
|
||||
</p>
|
||||
)}
|
||||
{refreshError && (
|
||||
<p className="mb-3 text-sm text-red-600">Last refresh failed: {refreshError}</p>
|
||||
)}
|
||||
|
||||
{syncResult && (
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
Checked {syncResult.checked} · newly signed {syncResult.signed} · declined{' '}
|
||||
{syncResult.declined} · failed {syncResult.failed}
|
||||
{syncResult.warnings.length > 0 && (
|
||||
<span className="text-amber-700"> · {syncResult.warnings.join('; ')}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{notice && <p className="mb-3 text-sm text-gray-600">{notice}</p>}
|
||||
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<table className="w-full border-collapse bg-white text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||
<th className="w-28 px-3 py-2 font-medium">Date</th>
|
||||
<th className="px-3 py-2 font-medium">Signer</th>
|
||||
<th className="px-3 py-2 font-medium">E-mail</th>
|
||||
<th className="w-24 px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Title</th>
|
||||
<th className="px-3 py-2 font-medium">Buyer</th>
|
||||
<th className="px-3 py-2 font-medium">Deal</th>
|
||||
<th className="w-24 px-3 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows?.map((row) => (
|
||||
<tr key={row.signature_request_id} className="border-b border-gray-100 align-top">
|
||||
<td className="px-3 py-1.5 text-gray-500">
|
||||
<DateTime iso={row.created_at} />
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{row.signer.name || '—'}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{row.signer.email || '—'}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-xs whitespace-nowrap ${
|
||||
STATUS_TONE[row.status]
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{row.title_remainder || '—'}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{row.known_buyer ? (
|
||||
<button
|
||||
onClick={() => row.known_buyer && onOpenBuyer(row.known_buyer.buyer_id)}
|
||||
className="rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-xs text-blue-800"
|
||||
>
|
||||
Known: {row.known_buyer.company_name ?? row.known_buyer.contact_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">new</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{row.imported ? (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
) : (
|
||||
<select
|
||||
value={picked[row.signature_request_id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setPicked({ ...picked, [row.signature_request_id]: e.target.value })
|
||||
}
|
||||
className="w-56 rounded border border-gray-300 px-1.5 py-1 text-sm"
|
||||
>
|
||||
<option value="">— no deal —</option>
|
||||
{row.business_suggestions.map((business) => (
|
||||
<option key={business.id} value={business.id}>
|
||||
{business.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{row.imported ? (
|
||||
<button
|
||||
onClick={() => row.imported && onOpenBuyer(row.imported.buyer_id)}
|
||||
className="text-sm text-green-700 hover:underline"
|
||||
title="Already imported — open the buyer"
|
||||
>
|
||||
✓ imported
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => importRow(row)}
|
||||
disabled={busy === row.signature_request_id}
|
||||
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-50"
|
||||
>
|
||||
{busy === row.signature_request_id ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-4 text-gray-500">
|
||||
{inbox?.last_refresh_at
|
||||
? 'No NDA signature requests in this period.'
|
||||
: 'Nothing fetched yet — press Refresh to load the signature requests.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user