module 6a

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

View File

@@ -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)"
]
}
}

View File

@@ -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
View File

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

View File

@@ -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}

View 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;

View 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()
);

View File

@@ -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],
);

View File

@@ -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
View 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';
}

View File

@@ -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
View 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
View 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
View 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;
}

View File

@@ -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');

View File

@@ -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],

View File

@@ -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 })} />
)}

View File

@@ -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));
}

View File

@@ -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',

View File

@@ -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
View 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>
);
}