nda improvements

This commit is contained in:
2026-07-28 17:35:44 -05:00
parent 4653f53ff3
commit c9202d0b80
16 changed files with 35467 additions and 237 deletions

View File

@@ -63,7 +63,10 @@
"Bash(bash -n acceptance6b.sh)", "Bash(bash -n acceptance6b.sh)",
"Bash(curl -s -m 3 http://127.0.0.1:8099/__stats)", "Bash(curl -s -m 3 http://127.0.0.1:8099/__stats)",
"Bash(node *)", "Bash(node *)",
"Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance6c.sh)" "Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance6c.sh)",
"Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance6d.sh)",
"Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance6e.sh)",
"Bash(awk '{print \" size:\", $5, \"bytes modified:\", $6, $7, $8}')"
] ]
} }
} }

View File

@@ -12,6 +12,8 @@ NDA_REMINDER_DAYS=14
# Dropbox Sign: the NDA inbox routes answer 503 until this is set. # 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. # Keep the real key in .env only — .env is gitignored and the key is never logged.
DROPBOX_SIGN_API_KEY= DROPBOX_SIGN_API_KEY=
# Pending signature requests older than this are no longer polled by the sync
SYNC_PENDING_MAX_AGE_DAYS=60
# Where signed NDAs are filed (one directory per first letter of the last name) # 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 NDA_ROOT=/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z

View File

@@ -199,7 +199,7 @@ directory aborts the scan with an error naming the path.
| GET | /api/nda-inbox | mirrored signature requests, `?since=<iso date>` | 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/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/import | import one request into buyer/contact/nda(/deal) | yes |
| POST | /api/nda-inbox/sync | refresh the status of every pending imported NDA | yes | | POST | /api/nda-inbox/sync | background: re-check pending NDAs + stale mirror rows | yes |
| POST | /api/nda-inbox/backfill-fields | retrofit form fields onto signed rounds | yes | | POST | /api/nda-inbox/backfill-fields | retrofit form fields onto signed rounds | yes |
| GET | /api/ndas/:id/file | stream the filed NDA PDF (Range + ETag) | yes | | GET | /api/ndas/:id/file | stream the filed NDA PDF (Range + ETag) | yes |
@@ -318,15 +318,42 @@ are mirrored into `ds_request` by a background task, and
(status, signer, `imported`, `known_buyer` by exact normalised e-mail, up to (status, signer, `imported`, `known_buyer` by exact normalised e-mail, up to
five `business_suggestions` by word overlap with the title remainder) plus five `business_suggestions` by word overlap with the title remainder) plus
`last_refresh_at` and `refresh_state`. `last_refresh_at` and `refresh_state`.
* `POST /api/nda-inbox/refresh?since=` starts the walk and returns `202` * `POST /api/nda-inbox/refresh` starts the walk and returns `202` immediately,
immediately, or `409` when one is already running — the slot is claimed with or `409` when one is already running — the slot is claimed with a conditional
a conditional upsert on `app_meta`, so two clicks cannot start two walks. A upsert on `app_meta`, so two clicks cannot start two walks. A `running` state
`running` state left behind by a killed process is reset at startup. left behind by a killed process is reset at startup.
* **`?since=` means "re-read that whole window"**, so the UI only sends it when
the user actually moved the date picker, once; a plain Refresh posts without
it and catches up incrementally. Sending it on every click was what made the
refresh feel slow in production — the button then said Refresh but did a full
reload every time. The button reflects this: *Refresh* vs *Reload window*.
The task walks the pages newest-first with a 500 ms pause between calls and The task **searches server-side** rather than paging through everything and
upserts every NDA request into `ds_request`; re-walking is how a row that was discarding most of it. It sends
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 ```
query=title:"Buyer Forms - NDA" AND created:{<cutoff-date> TO *}
```
which turns a 13k-request account into the ~360 that are ours. The two
client-side filters are kept as safety nets: a title that should not have come
back is filtered out *and logged as a warning*, and the exact-timestamp check
catches rows from the cutoff day itself, since the API's date clause is only
day-granular. If the query were ever ignored, the mirror would still be correct
— just slow again, and the log would say so.
It pages through that filtered set with a 500 ms pause between calls; re-walking
is how a row that was pending last time is picked up as signed or declined.
Measured against the live account:
| | pages | seen | stored | duration |
| --- | --- | --- | --- | --- |
| full 90-day reload | 4 | 359 | 359 | 33 s |
| incremental, seconds later | 1 | 43 | 43 | 5 s |
`seen` and `stored` now match. The old gap (≈1000 seen, ≈360 stored, 10 pages,
a minute) *was* the problem: 97% of what it fetched was thrown away. On `429`/`409` it honours
`Retry-After` but waits at least 10s, retries a page up to three times, and `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 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 Dropbox means by the `409` it sometimes sends. `app_meta` holds
@@ -462,10 +489,33 @@ 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 cannot be downloaded or written returns a `warning` instead of rolling back an
import that already succeeded. import that already succeeded.
`POST /api/nda-inbox/sync` re-checks every NDA that still has status `SENT`, a `POST /api/nda-inbox/sync` runs in the background exactly like the refresh —
`dropbox_sign_id` and `declined = false`; it applies the signed rule and files `202` when started, `409` when one is already running, state and result in
the PDF for the ones that came in, flags the ones that were declined, and `app_meta` under `ds_sync_state` / `ds_last_sync_at` / `ds_last_sync_result`,
answers with `{checked, signed, declined, failed, warnings}`. and the UI polls until it reports the counts. It does two things:
1. Re-checks every NDA that still has status `SENT`, a `dropbox_sign_id` and
`declined = false`, applying the signed rule and filing the PDF for the ones
that came in, and flagging the ones that were declined.
2. Re-fetches mirror rows the incremental walk can no longer reach — but only
those worth asking about, which is what keeps the run finite:
| Bound | Why |
| --- | --- |
| `created_at` older than the refresh reaches | anything newer was just re-read by the walk |
| `fetched_at` older than 12 h | without it every run re-fetches the same few hundred rows |
| `created_at` within `SYNC_PENDING_MAX_AGE_DAYS` (env, default 60) | a request pending that long is realistically dead |
A request past the age cutoff is *not* deleted or hidden: it stays in the inbox
as pending and can still be imported by hand. We simply stop asking Dropbox
about it. The answer carries both halves apart —
`{checked, signed, declined, failed, mirror_candidates, mirror_rechecked,
mirror_changed, mirror_failed, warnings, warnings_omitted}` — because a mirror
row that cannot be re-read is a stale cache entry, not an NDA that failed.
Both background jobs share `src/background-task.ts`: the same atomic slot
claim, the same `idle` / `running` / `error:<msg>` state, the same
stale-state reset at startup.
`DROPBOX_SIGN_BASE_URL` exists so the whole flow can be exercised against a `DROPBOX_SIGN_BASE_URL` exists so the whole flow can be exercised against a
local stub; leave it unset in production. local stub; leave it unset in production.
@@ -575,6 +625,7 @@ src/
business-scan.ts NAS scan, recursive listing, safe file path resolution business-scan.ts NAS scan, recursive listing, safe file path resolution
dropbox-sign.ts thin Dropbox Sign REST client (list, get, download) dropbox-sign.ts thin Dropbox Sign REST client (list, get, download)
nda-files.ts naming, filing and active/inactive moves of NDA PDFs nda-files.ts naming, filing and active/inactive moves of NDA PDFs
background-task.ts slot claim + idle/running/error state for the two jobs
nda-refresh.ts the background walk that mirrors requests into ds_request nda-refresh.ts the background walk that mirrors requests into ds_request
nda-fields.ts response_data -> buyer/contact/nda, fill-only-what-is-empty nda-fields.ts response_data -> buyer/contact/nda, fill-only-what-is-empty
server.ts Fastify app (health, staff, login, businesses, file, static) server.ts Fastify app (health, staff, login, businesses, file, static)

View File

@@ -25,6 +25,7 @@ services:
NAS_ROOT: ${NAS_ROOT:-/mnt/bizmatch-nas} NAS_ROOT: ${NAS_ROOT:-/mnt/bizmatch-nas}
NDA_REMINDER_DAYS: ${NDA_REMINDER_DAYS:-14} NDA_REMINDER_DAYS: ${NDA_REMINDER_DAYS:-14}
DROPBOX_SIGN_API_KEY: ${DROPBOX_SIGN_API_KEY:-} DROPBOX_SIGN_API_KEY: ${DROPBOX_SIGN_API_KEY:-}
SYNC_PENDING_MAX_AGE_DAYS: ${SYNC_PENDING_MAX_AGE_DAYS:-60}
NDA_ROOT: ${NDA_ROOT:-/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z} 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_ACTIVE: ${NAS_DIR_ACTIVE:-AAA = ACTIVE}
NAS_DIR_SOLD: ${NAS_DIR_SOLD:-AAA = SOLD} NAS_DIR_SOLD: ${NAS_DIR_SOLD:-AAA = SOLD}

34741
nda.json Normal file

File diff suppressed because it is too large Load Diff

117
src/background-task.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { FastifyBaseLogger } from 'fastify';
import { query, queryOne } from './db.js';
/**
* Long-running jobs that must not sit in front of an HTTP request: the NDA
* refresh walk and the signature sync. Both talk to a rate-limited API for
* minutes at a time, so the route starts them and returns, and the UI polls
* the state out of app_meta.
*
* At most one run of each at a time, claimed atomically so two clicks on the
* same button cannot start two jobs.
*/
export interface TaskKeys {
/** idle | running | error:<message> */
state: string;
/** ISO timestamp of the last completed run. */
lastAt: string;
/** JSON result of the last completed run. */
lastResult: string;
}
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],
);
}
/**
* The conditional upsert *is* the lock: it returns nothing when somebody else
* already holds the slot.
*/
async function claim(keys: TaskKeys): 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`,
[keys.state],
);
return Boolean(claimed);
}
/**
* Tasks live in this process, so a "running" found at startup can only be the
* remains of a killed one.
*/
export async function clearStaleTask(
keys: TaskKeys,
label: string,
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`,
[keys.state],
);
if (stale) log.warn(`[${label}] found a stale "running" state at startup, reset to idle`);
}
/**
* Claims the slot and runs `job` detached. `false` means somebody else is
* already running it — the caller answers 409.
*/
export async function startTask<T>(
keys: TaskKeys,
label: string,
log: FastifyBaseLogger,
job: () => Promise<T>,
): Promise<boolean> {
if (!(await claim(keys))) return false;
void (async () => {
try {
const result = await job();
await setMeta(keys.lastResult, JSON.stringify(result));
await setMeta(keys.lastAt, new Date().toISOString());
await setMeta(keys.state, 'idle');
} catch (err) {
const message = (err as Error).message;
log.error(`[${label}] failed: ${message}`);
// Kept in the state so the UI can show why, rather than a silent idle.
await setMeta(keys.state, `error:${message}`.slice(0, 400)).catch(() => {});
}
})();
return true;
}
export interface TaskState {
state: string;
last_at: string | null;
last_result: unknown;
}
export async function readTaskState(keys: TaskKeys): Promise<TaskState> {
const [state, lastAt, lastResult] = await Promise.all([
getMeta(keys.state),
getMeta(keys.lastAt),
getMeta(keys.lastResult),
]);
return {
state: state ?? 'idle',
last_at: lastAt,
last_result: lastResult ? (JSON.parse(lastResult) as unknown) : null,
};
}

View File

@@ -16,6 +16,12 @@ export const config = {
dropboxSignApiKey: process.env.DROPBOX_SIGN_API_KEY ?? '', dropboxSignApiKey: process.env.DROPBOX_SIGN_API_KEY ?? '',
/** Overridable so the inbox can be exercised against a local stub in tests. */ /** Overridable so the inbox can be exercised against a local stub in tests. */
dropboxSignBaseUrl: process.env.DROPBOX_SIGN_BASE_URL ?? 'https://api.hellosign.com', dropboxSignBaseUrl: process.env.DROPBOX_SIGN_BASE_URL ?? 'https://api.hellosign.com',
/**
* A request pending longer than this is realistically dead: the sync stops
* polling Dropbox for it. It stays visible in the inbox and can still be
* imported by hand.
*/
syncPendingMaxAgeDays: Number(process.env.SYNC_PENDING_MAX_AGE_DAYS ?? 60),
/** Where signed NDAs are filed, one directory per first letter of the last name */ /** 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", 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 */ /** Directory names directly below NAS_ROOT, one per business status */

View File

@@ -95,29 +95,51 @@ async function getJson<T>(path: string): Promise<T> {
} }
export interface SignatureRequestPage { export interface SignatureRequestPage {
/** Everything on the page, unfiltered — the caller needs the oldest entry. */ /** Everything on the page as returned. */
requests: SignatureRequest[]; requests: SignatureRequest[];
/** Only the ones this app cares about. */ /** Only the ones this app cares about — the safety net over the query. */
ndaRequests: SignatureRequest[]; ndaRequests: SignatureRequest[];
page: number; page: number;
numPages: number; numPages: number;
numResults: number;
}
/**
* The search the refresh runs against the list endpoint. Filtering server-side
* is the difference between reading 13k requests and reading ~330: without it
* the walk pages through every signature request the account ever had and
* throws away the 97% that are not ours.
*
* `created:{<date> TO *}` is the API's range syntax; the date is a plain
* calendar day, which is as precise as the filter goes.
*/
export function ndaSearchQuery(since: Date): string {
const day = since.toISOString().slice(0, 10);
return `title:"${NDA_TITLE_PREFIX}" AND created:{${day} TO *}`;
} }
/** /**
* One page of the list endpoint, newest first. Paging is driven by the caller * 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. * (see nda-refresh.ts) so it can pace the calls and handle throttling.
*/ */
export async function listPage(page: number, pageSize = 100): Promise<SignatureRequestPage> { export async function listPage(
page: number,
pageSize = 100,
query?: string,
): Promise<SignatureRequestPage> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (query) params.set('query', query);
const body = await getJson<{ const body = await getJson<{
signature_requests?: SignatureRequest[]; signature_requests?: SignatureRequest[];
list_info?: { num_pages?: number; page?: number }; list_info?: { num_pages?: number; page?: number; num_results?: number };
}>(`/v3/signature_request/list?page=${page}&page_size=${pageSize}`); }>(`/v3/signature_request/list?${params.toString()}`);
const requests = body.signature_requests ?? []; const requests = body.signature_requests ?? [];
return { return {
requests, requests,
ndaRequests: requests.filter((request) => request.title?.startsWith(NDA_TITLE_PREFIX)), ndaRequests: requests.filter((request) => request.title?.startsWith(NDA_TITLE_PREFIX)),
page: body.list_info?.page ?? page, page: body.list_info?.page ?? page,
numPages: body.list_info?.num_pages ?? 1, numPages: body.list_info?.num_pages ?? 1,
numResults: body.list_info?.num_results ?? requests.length,
}; };
} }

View File

@@ -14,11 +14,20 @@ import {
} from './dropbox-sign.js'; } from './dropbox-sign.js';
import { import {
META_LAST_REFRESH, META_LAST_REFRESH,
META_STATE, OVERLAP_MS,
REFRESH_KEYS,
clearStaleRefreshState, clearStaleRefreshState,
getMeta,
startRefresh, startRefresh,
upsertRequests,
} from './nda-refresh.js'; } from './nda-refresh.js';
import {
type TaskKeys,
clearStaleTask,
getMeta,
readTaskState,
startTask,
} from './background-task.js';
import { config } from './config.js';
import { NdaFileError, resolveNdaFile, writeNdaFile } from './nda-files.js'; import { NdaFileError, resolveNdaFile, writeNdaFile } from './nda-files.js';
import { applyFormData } from './nda-fields.js'; import { applyFormData } from './nda-fields.js';
import { UUID_RE, badId, sendFile, trimmed } from './http.js'; import { UUID_RE, badId, sendFile, trimmed } from './http.js';
@@ -26,9 +35,21 @@ import { UUID_RE, badId, sendFile, trimmed } from './http.js';
/** How far back the inbox looks when the caller does not say. */ /** How far back the inbox looks when the caller does not say. */
const DEFAULT_WINDOW_DAYS = 90; const DEFAULT_WINDOW_DAYS = 90;
/** Between per-request fetches in the retrofit, same courtesy as the refresh. */ /** Between per-request fetches in the sync and retrofit, as in the refresh. */
const FETCH_PAUSE_MS = 500; const FETCH_PAUSE_MS = 500;
/**
* A mirror row read this recently is not read again. Without it every sync
* would re-fetch the same few hundred pending requests.
*/
const MIRROR_RECHECK_AFTER_HOURS = 12;
export const SYNC_KEYS: TaskKeys = {
state: 'ds_sync_state',
lastAt: 'ds_last_sync_at',
lastResult: 'ds_last_sync_result',
};
/** Words too common in a business name to say anything about a match. */ /** Words too common in a business name to say anything about a match. */
const STOPWORDS = new Set([ const STOPWORDS = new Set([
'the', 'the',
@@ -181,6 +202,136 @@ async function fileSignedPdf(
} }
} }
/**
* Catches up with whatever happened on the Dropbox side: signatures that came
* in, and requests that were declined. Runs as a background task.
*/
async function syncSignatures(
app: FastifyInstance,
staffId: string,
): Promise<Record<string, unknown>> {
// 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; buyer_id: string }>(
`SELECT id, dropbox_sign_id, buyer_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);
// The signature brought the form answers with it.
await applyFormData(tx, {
ndaId: nda.id,
buyerId: nda.buyer_id,
contactId: await primaryContactId(tx, nda.buyer_id),
signerName: signer.name,
signerEmail: signer.email,
responseData: request.response_data,
staffId,
});
});
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-sync] sync of ${nda.id} failed: ${(err as Error).message}`);
warnings.push(`${nda.dropbox_sign_id}: ${(err as Error).message}`);
failed += 1;
}
}
// An incremental refresh only re-reads the newest pages, so a request that
// has been pending for weeks would never be looked at again. Those are
// re-fetched here one by one, bounded three ways so the run stays finite:
// older than the refresh reaches, not re-read in the last few hours, and
// not so old that nobody is waiting for it any more.
const lastRefresh = await getMeta(META_LAST_REFRESH);
const staleBefore = new Date(
(lastRefresh ? new Date(lastRefresh).getTime() : Date.now()) - OVERLAP_MS,
);
const stale = await query<{ signature_request_id: string }>(
`SELECT signature_request_id FROM ds_request
WHERE status = 'pending'
AND created_at < $1
AND created_at >= now() - ($2::int * interval '1 day')
AND fetched_at < now() - ($3::int * interval '1 hour')
ORDER BY created_at DESC`,
[staleBefore.toISOString(), config.syncPendingMaxAgeDays, MIRROR_RECHECK_AFTER_HOURS],
);
let mirrored = 0;
let mirrorChanged = 0;
let mirrorFailed = 0;
for (const [index, row] of stale.entries()) {
if (index > 0) await new Promise((resolve) => setTimeout(resolve, FETCH_PAUSE_MS));
try {
const request = await getSignatureRequest(row.signature_request_id);
await upsertRequests([request]);
mirrored += 1;
if (statusOf(request) !== 'pending') mirrorChanged += 1;
} catch (err) {
const message = (err as Error).message;
app.log.warn(`[nda-sync] re-checking ${row.signature_request_id} failed: ${message}`);
warnings.push(`${row.signature_request_id}: ${message}`);
// Counted apart from `failed`: a mirror row that cannot be re-read is
// a stale cache entry, not an imported NDA that failed to sync.
mirrorFailed += 1;
}
}
app.log.info(
`[nda-sync] ${pending.length} NDA(s) checked, ${signed} signed, ${declined} declined; ` +
`${mirrored}/${stale.length} mirror row(s) re-read, ${mirrorChanged} moved on`,
);
// A run over a few hundred stale rows would otherwise answer with a few
// hundred warning strings; the count says how many were left out.
const MAX_WARNINGS = 10;
return {
checked: pending.length,
signed,
declined,
failed,
mirror_candidates: stale.length,
mirror_rechecked: mirrored,
mirror_changed: mirrorChanged,
mirror_failed: mirrorFailed,
warnings: warnings.slice(0, MAX_WARNINGS),
warnings_omitted: Math.max(0, warnings.length - MAX_WARNINGS),
};
}
/** /**
* The NDA inbox: what Dropbox Sign has, what of it is already in the database, * 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 + * and the one-click import that turns a signature request into buyer +
@@ -189,7 +340,10 @@ async function fileSignedPdf(
export function registerNdaInboxRoutes(app: FastifyInstance): void { export function registerNdaInboxRoutes(app: FastifyInstance): void {
// The task lives in this process, so a "running" left in app_meta can only // The task lives in this process, so a "running" left in app_meta can only
// be the remains of a kill. // be the remains of a kill.
app.addHook('onReady', () => clearStaleRefreshState(app.log)); app.addHook('onReady', async () => {
await clearStaleRefreshState(app.log);
await clearStaleTask(SYNC_KEYS, 'nda-sync', app.log);
});
/** /**
* Reads ds_request only — never Dropbox. Mounting the view is one indexed * Reads ds_request only — never Dropbox. Mounting the view is one indexed
@@ -256,7 +410,11 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
ORDER BY c.is_primary DESC, c.created_at`, ORDER BY c.is_primary DESC, c.created_at`,
[emails], [emails],
); );
const businesses = await query<BusinessRow>('SELECT id, name, status FROM business'); // Only businesses that are actually on the market can be suggested; a
// sold or inactive one is never the right deal for a fresh NDA.
const businesses = await query<BusinessRow>(
`SELECT id, name, status FROM business WHERE status = 'ACTIVE'`,
);
const requests = rows.map((row) => { const requests = rows.map((row) => {
const remainder = titleRemainder(row.title ?? '', row.signer_name ?? ''); const remainder = titleRemainder(row.title ?? '', row.signer_name ?? '');
@@ -283,10 +441,19 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
}; };
}); });
const [refresh, sync] = await Promise.all([
readTaskState(REFRESH_KEYS),
readTaskState(SYNC_KEYS),
]);
return { return {
requests, requests,
last_refresh_at: await getMeta(META_LAST_REFRESH), last_refresh_at: refresh.last_at,
refresh_state: (await getMeta(META_STATE)) ?? 'idle', refresh_state: refresh.state,
/** {pages, seen, stored} of the last completed walk, null before the first. */
last_refresh_result: refresh.last_result,
last_sync_at: sync.last_at,
sync_state: sync.state,
last_sync_result: sync.last_result,
}; };
}); });
@@ -305,7 +472,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
return reply.code(400).send({ error: `invalid since: ${sinceParam}` }); return reply.code(400).send({ error: `invalid since: ${sinceParam}` });
} }
if (!(await startRefresh(app.log, since))) { // An explicit ?since= is a deliberate request for a wider re-read and
// therefore skips the incremental cutoff.
if (!(await startRefresh(app.log, since, Boolean(sinceParam)))) {
return reply.code(409).send({ error: 'a refresh is already running', state: 'running' }); return reply.code(409).send({ error: 'a refresh is already running', state: 'running' });
} }
return reply.code(202).send({ state: 'running', since: since.toISOString() }); return reply.code(202).send({ state: 'running', since: since.toISOString() });
@@ -446,68 +615,13 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
const staffId = staffIdFromRequest(req); const staffId = staffIdFromRequest(req);
if (!staffId) return reply.code(401).send({ error: 'not signed in' }); if (!staffId) return reply.code(401).send({ error: 'not signed in' });
// Declined rounds are excluded: they stay SENT forever but will never // Same shape as the refresh: it fetches per id at 500ms over what can be
// change state on the Dropbox side again, so re-fetching them is waste. // hundreds of rows, which is minutes of work and has no business sitting
const pending = await query<{ id: string; dropbox_sign_id: string; buyer_id: string }>( // in front of an HTTP request.
`SELECT id, dropbox_sign_id, buyer_id FROM nda if (!(await startTask(SYNC_KEYS, 'nda-sync', app.log, () => syncSignatures(app, staffId)))) {
WHERE dropbox_sign_id IS NOT NULL AND status = 'SENT' AND NOT declined return reply.code(409).send({ error: 'a sync is already running', state: 'running' });
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);
// The signature brought the form answers with it.
await applyFormData(tx, {
ndaId: nda.id,
buyerId: nda.buyer_id,
contactId: await primaryContactId(tx, nda.buyer_id),
signerName: signer.name,
signerEmail: signer.email,
responseData: request.response_data,
staffId,
});
});
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 reply.code(202).send({ state: 'running' });
return { checked: pending.length, signed, declined, failed, warnings };
}); });
/** /**

View File

@@ -1,6 +1,14 @@
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { query, queryOne, withTransaction } from './db.js'; import { withTransaction } from './db.js';
import { type SignatureRequest, isRateLimited, listPage, statusOf } from './dropbox-sign.js'; import {
NDA_TITLE_PREFIX,
type SignatureRequest,
isRateLimited,
listPage,
ndaSearchQuery,
statusOf,
} from './dropbox-sign.js';
import { type TaskKeys, clearStaleTask, getMeta, startTask } from './background-task.js';
/** /**
* Mirrors the Dropbox Sign signature requests into ds_request in the * Mirrors the Dropbox Sign signature requests into ds_request in the
@@ -20,59 +28,29 @@ const MAX_PAGE_ATTEMPTS = 3;
/** Safety net against a window that never reaches its cutoff. */ /** Safety net against a window that never reaches its cutoff. */
const MAX_PAGES = 50; const MAX_PAGES = 50;
export const META_LAST_REFRESH = 'ds_last_refresh_at'; export const REFRESH_KEYS: TaskKeys = {
export const META_STATE = 'ds_refresh_state'; state: 'ds_refresh_state',
lastAt: 'ds_last_refresh_at',
lastResult: 'ds_last_refresh_result',
};
export const META_LAST_REFRESH = REFRESH_KEYS.lastAt;
/**
* How far back a follow-up walk reaches beyond the previous refresh. Generous
* on purpose: a request created just before the last run, or a clock skew
* between us and Dropbox, must not fall through the gap.
*/
export const OVERLAP_MS = 48 * 60 * 60 * 1000;
export type RefreshState = 'idle' | 'running' | `error:${string}`; export type RefreshState = 'idle' | 'running' | `error:${string}`;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 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 * Mirrors a batch of requests. Exported because the sync route re-checks old
* two walks. The conditional upsert is the lock; it returns nothing when * pending rows one by one, which an incremental walk no longer reaches.
* somebody else already holds it.
*/ */
async function claimRefreshSlot(): Promise<boolean> { export async function upsertRequests(requests: SignatureRequest[]): Promise<void> {
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; if (requests.length === 0) return;
// One transaction per page: a page either lands whole or not at all. // One transaction per page: a page either lands whole or not at all.
await withTransaction(async (tx) => { await withTransaction(async (tx) => {
@@ -116,12 +94,13 @@ export interface RefreshResult {
/** Fetches one page, backing off and retrying while Dropbox throttles us. */ /** Fetches one page, backing off and retrying while Dropbox throttles us. */
async function fetchPageWithBackoff( async function fetchPageWithBackoff(
page: number, page: number,
query: string,
log: FastifyBaseLogger, log: FastifyBaseLogger,
loggedBody: { done: boolean }, loggedBody: { done: boolean },
) { ) {
for (let attempt = 1; ; attempt += 1) { for (let attempt = 1; ; attempt += 1) {
try { try {
return await listPage(page, 100); return await listPage(page, 100, query);
} catch (err) { } catch (err) {
if (!isRateLimited(err) || attempt >= MAX_PAGE_ATTEMPTS) throw 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 // Log the body once per run: we still do not know what Dropbox means by
@@ -143,56 +122,95 @@ async function fetchPageWithBackoff(
} }
/** /**
* Walks the list newest-first and upserts every NDA request created on or * How far back this walk has to reach.
* 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. * A full window is only needed the first time. Afterwards everything older
* than the previous refresh (minus the overlap) is already mirrored, so the
* walk stops after a page or two instead of re-reading a thousand requests
* every run. An explicitly requested `since` always wins — that is how the
* caller asks for a deliberate re-read of a wider period.
*/ */
async function walk(since: Date, log: FastifyBaseLogger): Promise<RefreshResult> { export async function refreshCutoff(since: Date, explicitSince: boolean): Promise<Date> {
const cutoff = Math.floor(since.getTime() / 1000); if (explicitSince) return since;
const last = await getMeta(META_LAST_REFRESH);
if (!last) return since; // first run: the whole window
const incremental = new Date(new Date(last).getTime() - OVERLAP_MS);
if (Number.isNaN(incremental.getTime())) return since;
return incremental > since ? incremental : since;
}
/**
* Walks the NDA requests created since `cutoffAt` and mirrors them.
*
* The filtering happens server-side via the search query, so the pages this
* walks are already ours: `seen` and `stored` should come out roughly equal.
* The two client-side filters below are safety nets — if the query were ever
* ignored or changed, they keep the mirror correct and say so in the log.
*/
async function walk(cutoffAt: Date, log: FastifyBaseLogger): Promise<RefreshResult> {
const cutoff = Math.floor(cutoffAt.getTime() / 1000);
const query = ndaSearchQuery(cutoffAt);
const loggedBody = { done: false }; const loggedBody = { done: false };
let stored = 0; let stored = 0;
let seen = 0; let seen = 0;
let foreign = 0;
let page = 1; let page = 1;
for (; page <= MAX_PAGES; page += 1) { for (; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_PAUSE_MS); if (page > 1) await sleep(PAGE_PAUSE_MS);
const result = await fetchPageWithBackoff(page, log, loggedBody); const result = await fetchPageWithBackoff(page, query, log, loggedBody);
if (result.requests.length === 0) break; if (result.requests.length === 0) break;
seen += result.requests.length; seen += result.requests.length;
// Safety net 1: a title the search should have excluded.
const wrongTitle = result.requests.length - result.ndaRequests.length;
if (wrongTitle > 0) {
foreign += wrongTitle;
if (foreign === wrongTitle) {
const example = result.requests.find(
(request) => !request.title?.startsWith(NDA_TITLE_PREFIX),
);
log.warn(
`[nda-refresh] the search returned ${wrongTitle} request(s) with a foreign title on ` +
`page ${page} — filtering them out here; e.g. ${JSON.stringify(example?.title ?? '')}`,
);
}
}
// Safety net 2: the date clause is day-granular, so a row from the cutoff
// day itself can come back slightly too old.
const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff); const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff);
await upsertPage(inWindow); await upsertRequests(inWindow);
stored += inWindow.length; 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; if (result.page >= result.numPages) break;
} }
log.info(`[nda-refresh] walked ${page} page(s), saw ${seen}, stored ${stored} NDA request(s)`); const pages = Math.min(page, MAX_PAGES);
return { pages: page, stored, seen }; log.info(
`[nda-refresh] walked ${pages} page(s) with query since ${cutoffAt.toISOString().slice(0, 10)}, ` +
`saw ${seen}, stored ${stored} NDA request(s)` +
(foreign > 0 ? `, discarded ${foreign} foreign title(s)` : ''),
);
return { pages, stored, seen };
} }
/** /**
* Starts the walk detached and returns immediately. `false` means somebody * Starts the walk detached and returns immediately. `false` means somebody
* else is already refreshing. * else is already refreshing.
*/ */
export async function startRefresh(log: FastifyBaseLogger, since: Date): Promise<boolean> { export async function startRefresh(
if (!(await claimRefreshSlot())) return false; log: FastifyBaseLogger,
since: Date,
void (async () => { explicitSince = false,
try { ): Promise<boolean> {
await walk(since, log); return startTask(REFRESH_KEYS, 'nda-refresh', log, async () => {
await setMeta(META_LAST_REFRESH, new Date().toISOString()); // Resolved inside the task: the cutoff depends on the previous refresh,
await setMeta(META_STATE, 'idle'); // and the slot claim is what serialises two runs against each other.
} catch (err) { const cutoff = await refreshCutoff(since, explicitSince);
const message = (err as Error).message; return walk(cutoff, log);
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;
} }
export const clearStaleRefreshState = (log: FastifyBaseLogger) =>
clearStaleTask(REFRESH_KEYS, 'nda-refresh', log);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ApiError, api, type Staff } from './api.js'; import { ApiError, api, type Staff } from './api.js';
import { StaffName } from './staff-color.js';
import Login from './views/Login.js'; import Login from './views/Login.js';
import Today from './views/Today.js'; import Today from './views/Today.js';
import NdaInbox from './views/NdaInbox.js'; import NdaInbox from './views/NdaInbox.js';
@@ -92,7 +93,7 @@ export default function App() {
</nav> </nav>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-gray-600">{staff.name}</span> <StaffName id={staff.id} name={staff.name} />
<button <button
className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100" className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100"
onClick={signOut} onClick={signOut}

View File

@@ -283,11 +283,23 @@ export interface InboxRow {
export type RefreshState = 'idle' | 'running' | string; export type RefreshState = 'idle' | 'running' | string;
export interface RefreshResult {
pages: number;
seen: number;
stored: number;
}
export interface Inbox { export interface Inbox {
requests: InboxRow[]; requests: InboxRow[];
/** ISO timestamp of the last completed background refresh, null if never. */ /** ISO timestamp of the last completed background refresh, null if never. */
last_refresh_at: string | null; last_refresh_at: string | null;
refresh_state: RefreshState; refresh_state: RefreshState;
/** What the last completed walk did, null before the first one. */
last_refresh_result: RefreshResult | null;
/** The sync runs in the background too, with the same state pattern. */
last_sync_at: string | null;
sync_state: RefreshState;
last_sync_result: SyncResult | null;
} }
export interface ImportResult { export interface ImportResult {
@@ -314,7 +326,13 @@ export interface SyncResult {
signed: number; signed: number;
declined: number; declined: number;
failed: number; failed: number;
/** Old pending mirror rows re-fetched one by one, and how many had moved on. */
mirror_candidates: number;
mirror_rechecked: number;
mirror_changed: number;
mirror_failed: number;
warnings: string[]; warnings: string[];
warnings_omitted: number;
} }
export class ApiError extends Error { export class ApiError extends Error {
@@ -422,8 +440,12 @@ export const api = {
/** Reads the mirrored requests out of the database — never calls Dropbox. */ /** Reads the mirrored requests out of the database — never calls Dropbox. */
ndaInbox: (since: string) => request<Inbox>(`/api/nda-inbox?${qs({ since })}`), ndaInbox: (since: string) => request<Inbox>(`/api/nda-inbox?${qs({ since })}`),
/** Starts the background walk; resolves as soon as it is queued. */ /**
refreshInbox: (since: string) => * Starts the background walk; resolves as soon as it is queued. Passing
* `since` makes the server re-read that whole window — leave it out for the
* incremental catch-up, which is what a plain Refresh should do.
*/
refreshInbox: (since?: string) =>
post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`), post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`),
importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) => importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) =>
post<ImportResult>('/api/nda-inbox/import', { post<ImportResult>('/api/nda-inbox/import', {
@@ -431,7 +453,7 @@ export const api = {
buyer_id: buyerId, buyer_id: buyerId,
business_id: businessId, business_id: businessId,
}), }),
syncInbox: () => post<SyncResult>('/api/nda-inbox/sync'), syncInbox: () => post<{ state: RefreshState }>('/api/nda-inbox/sync'),
backfillFields: () => post<BackfillResult>('/api/nda-inbox/backfill-fields'), backfillFields: () => post<BackfillResult>('/api/nda-inbox/backfill-fields'),
}; };

71
web/src/staff-color.tsx Normal file
View File

@@ -0,0 +1,71 @@
/**
* A stable colour per staff member, so "who did this" is readable at a glance
* without reading the name. Derived from the id, which means it survives
* reloads, sessions and renames, and needs nothing stored anywhere.
*/
/**
* Ten hues that stay apart from each other and from the greys the app uses.
* Written out in full because Tailwind only ships classes it can see in the
* source — a generated `text-${hue}-700` would be purged.
*/
export interface StaffColor {
text: string;
dot: string;
}
const PALETTE: StaffColor[] = [
{ text: 'text-blue-700', dot: 'bg-blue-500' },
{ text: 'text-emerald-700', dot: 'bg-emerald-500' },
{ text: 'text-violet-700', dot: 'bg-violet-500' },
{ text: 'text-amber-700', dot: 'bg-amber-500' },
{ text: 'text-rose-700', dot: 'bg-rose-500' },
{ text: 'text-cyan-700', dot: 'bg-cyan-500' },
{ text: 'text-lime-700', dot: 'bg-lime-600' },
{ text: 'text-fuchsia-700', dot: 'bg-fuchsia-500' },
{ text: 'text-orange-700', dot: 'bg-orange-500' },
{ text: 'text-teal-700', dot: 'bg-teal-600' },
];
/** FNV-1a — short, stable, and well spread over ten buckets. */
function hash(value: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return Math.abs(h);
}
/** Grey stands in for "nobody" — a deleted staff member or an unassigned row. */
const UNKNOWN: StaffColor = { text: 'text-gray-500', dot: 'bg-gray-400' };
export function staffColor(id: string | null | undefined): StaffColor {
if (!id) return UNKNOWN;
return PALETTE[hash(id) % PALETTE.length] ?? UNKNOWN;
}
/**
* A staff member's name in their colour. Deliberately understated — a dot and
* tinted text, never a filled pill, so a list of names does not turn into a
* traffic light.
*/
export function StaffName({
id,
name,
dot = true,
className = '',
}: {
id: string | null | undefined;
name: string | null | undefined;
dot?: boolean;
className?: string;
}) {
const color = staffColor(id);
return (
<span className={`inline-flex items-center gap-1 ${color.text} ${className}`}>
{dot && <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />}
{name ?? 'unknown'}
</span>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { api, type Inbox, type InboxRow, type InboxStatus, type SyncResult } from '../api.js'; import { api, type Inbox, type InboxRow, type InboxStatus } from '../api.js';
import { formatDayTimeParts, formatRelative } from '../components.js'; import { formatDayTimeParts, formatRelative } from '../components.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm'; const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm';
@@ -37,22 +37,34 @@ export default function NdaInbox({
onChanged: () => void; onChanged: () => void;
}) { }) {
const [since, setSince] = useState(() => isoDaysAgo(90)); const [since, setSince] = useState(() => isoDaysAgo(90));
// Sending ?since= tells the server to re-read the whole window, which is a
// minute of paging. A plain Refresh must not do that — only a date the user
// actually moved since the last run asks for the wide reload, once.
const [sinceChanged, setSinceChanged] = useState(false);
const [inbox, setInbox] = useState<Inbox | null>(null); const [inbox, setInbox] = useState<Inbox | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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". // Business chosen per row; '' means "import the NDA without a deal".
const [picked, setPicked] = useState<Record<string, string>>({}); const [picked, setPicked] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null); // Which row is mid-import, if any.
const [importing, setImporting] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const poll = useRef<number | null>(null); const poll = useRef<number | null>(null);
// Set when a task started *here* finishes, so its result is reported once
// rather than on every mount.
const [refreshNotice, setRefreshNotice] = useState<string | null>(null);
const [syncNotice, setSyncNotice] = useState<string | null>(null);
const watchingRefresh = useRef(false);
const watchingSync = useRef(false);
const rows: InboxRow[] | null = inbox?.requests ?? null; const rows: InboxRow[] | null = inbox?.requests ?? null;
const refreshing = inbox?.refresh_state === 'running'; const refreshing = inbox?.refresh_state === 'running';
const refreshError = inbox?.refresh_state?.startsWith('error:') const syncing = inbox?.sync_state === 'running';
? inbox.refresh_state.slice('error:'.length) // While either task runs the table keeps polling; both report when done.
: null; const busy = refreshing || syncing;
const taskError = [inbox?.refresh_state, inbox?.sync_state]
.find((state) => state?.startsWith('error:'))
?.slice('error:'.length);
/** Reads the mirror out of the database — this never talks to Dropbox. */ /** Reads the mirror out of the database — this never talks to Dropbox. */
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -61,16 +73,9 @@ export default function NdaInbox({
const res = await api.ndaInbox(since); const res = await api.ndaInbox(since);
setInbox(res); setInbox(res);
setError(null); setError(null);
// Preselect the best suggestion, but never overwrite a manual choice. // Deliberately no preselection: a suggestion is a guess, and an import
setPicked((current) => { // must never attach a deal the user did not pick. Anything already
const next = { ...current }; // chosen by hand is kept across reloads.
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; return res;
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
@@ -87,7 +92,31 @@ export default function NdaInbox({
// Whenever a walk is in flight — started here or elsewhere — watch it until // Whenever a walk is in flight — started here or elsewhere — watch it until
// it finishes, then show whatever it brought in. // it finishes, then show whatever it brought in.
useEffect(() => { useEffect(() => {
if (!refreshing) { if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
watchingRefresh.current = false;
const { pages, seen, stored } = inbox.last_refresh_result;
setRefreshNotice(
`Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` +
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`,
);
}
if (!syncing && watchingSync.current && inbox?.last_sync_result) {
watchingSync.current = false;
const r = inbox.last_sync_result;
setSyncNotice(
`Synced: ${r.checked} NDA(s) checked, ${r.signed} newly signed, ` +
`${r.declined} declined` +
(r.failed > 0 ? `, ${r.failed} failed` : '') +
` · ${r.mirror_rechecked}/${r.mirror_candidates} older request(s) re-read, ` +
`${r.mirror_changed} had moved on` +
(r.mirror_failed > 0 ? `, ${r.mirror_failed} unreadable` : '') +
(r.warnings.length > 0
? ` · ${r.warnings.join('; ')}${r.warnings_omitted > 0 ? ` (+${r.warnings_omitted} more)` : ''}`
: ''),
);
}
if (!busy) {
if (poll.current !== null) { if (poll.current !== null) {
window.clearInterval(poll.current); window.clearInterval(poll.current);
poll.current = null; poll.current = null;
@@ -102,12 +131,16 @@ export default function NdaInbox({
poll.current = null; poll.current = null;
} }
}; };
}, [refreshing, load]); }, [busy, refreshing, syncing, load, inbox?.last_refresh_result, inbox?.last_sync_result]);
async function refresh() { async function refresh() {
setError(null); setError(null);
setRefreshNotice(null);
try { try {
await api.refreshInbox(since); // Only a moved date asks for the full window; then back to incremental.
await api.refreshInbox(sinceChanged ? since : undefined);
setSinceChanged(false);
watchingRefresh.current = true;
// Flip to "running" at once so the poller starts without a round trip. // Flip to "running" at once so the poller starts without a round trip.
setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current)); setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current));
} catch (err) { } catch (err) {
@@ -116,22 +149,20 @@ export default function NdaInbox({
} }
async function sync() { async function sync() {
setSyncing(true);
setSyncResult(null);
setError(null); setError(null);
setSyncNotice(null);
try { try {
setSyncResult(await api.syncInbox()); await api.syncInbox();
await load(); watchingSync.current = true;
onChanged(); // Flip to "running" at once so the poller starts without a round trip.
setInbox((current) => (current ? { ...current, sync_state: 'running' } : current));
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally {
setSyncing(false);
} }
} }
async function importRow(row: InboxRow) { async function importRow(row: InboxRow) {
setBusy(row.signature_request_id); setImporting(row.signature_request_id);
setNotice(null); setNotice(null);
setError(null); setError(null);
try { try {
@@ -151,7 +182,7 @@ export default function NdaInbox({
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally { } finally {
setBusy(null); setImporting(null);
} }
} }
@@ -164,7 +195,11 @@ export default function NdaInbox({
<input <input
type="date" type="date"
value={since} value={since}
onChange={(e) => setSince(e.target.value)} onChange={(e) => {
setSince(e.target.value);
setSinceChanged(true);
}}
title="Changing the date makes the next Refresh re-read that whole window"
className={INPUT} className={INPUT}
/> />
</label> </label>
@@ -177,10 +212,14 @@ export default function NdaInbox({
<button <button
onClick={refresh} onClick={refresh}
disabled={refreshing} disabled={refreshing}
title="Fetches the signature requests from Dropbox Sign in the background" title={
sinceChanged
? 'Reload the whole window from the changed date'
: 'Refresh (new since last check)'
}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50" 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'} {refreshing ? 'Refreshing…' : sinceChanged ? 'Reload window' : 'Refresh'}
</button> </button>
<button <button
onClick={sync} onClick={sync}
@@ -192,24 +231,16 @@ export default function NdaInbox({
</div> </div>
</div> </div>
{refreshing && ( {busy && (
<p className="mb-3 text-sm text-gray-600"> <p className="mb-3 text-sm text-gray-600">
Fetching from Dropbox Sign in the background the table below stays usable. {refreshing ? 'Fetching from' : 'Re-checking with'} Dropbox Sign in the background the
table below stays usable.
</p> </p>
)} )}
{refreshError && ( {taskError && <p className="mb-3 text-sm text-red-600">Last run failed: {taskError}</p>}
<p className="mb-3 text-sm text-red-600">Last refresh failed: {refreshError}</p>
)}
{syncResult && ( {refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>}
<p className="mb-3 text-sm text-gray-600"> {syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</p>}
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>} {notice && <p className="mb-3 text-sm text-gray-600">{notice}</p>}
{error && <p className="mb-3 text-sm text-red-600">{error}</p>} {error && <p className="mb-3 text-sm text-red-600">{error}</p>}
@@ -289,10 +320,10 @@ export default function NdaInbox({
) : ( ) : (
<button <button
onClick={() => importRow(row)} onClick={() => importRow(row)}
disabled={busy === row.signature_request_id} disabled={importing === row.signature_request_id}
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-50" className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-50"
> >
{busy === row.signature_request_id ? 'Importing…' : 'Import'} {importing === row.signature_request_id ? 'Importing…' : 'Import'}
</button> </button>
)} )}
</td> </td>

View File

@@ -9,17 +9,30 @@ import {
} from '../api.js'; } from '../api.js';
import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js'; import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js';
import { TodoRow } from '../workflow.js'; import { TodoRow } from '../workflow.js';
import { StaffName } from '../staff-color.js';
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1 text-sm'; const INPUT = 'w-full rounded border border-gray-300 px-2 py-1 text-sm';
/** Groups anything with a person attached, for the team tab. */ interface Group<T> {
function groupBy<T>(rows: T[], name: (row: T) => string): [string, T[]][] { id: string | null;
const groups = new Map<string, T[]>(); name: string;
rows: T[];
}
/**
* Groups anything with a person attached, for the team tab. Keyed by staff id
* rather than name, so the group heading can carry that person's colour.
*/
function groupByStaff<T>(rows: T[], who: (row: T) => { id: string | null; name: string }): Group<T>[] {
const groups = new Map<string, Group<T>>();
for (const row of rows) { for (const row of rows) {
const key = name(row); const { id, name } = who(row);
groups.set(key, [...(groups.get(key) ?? []), row]); const key = id ?? name;
const group = groups.get(key) ?? { id, name, rows: [] };
group.rows.push(row);
groups.set(key, group);
} }
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
} }
export default function Today({ export default function Today({
@@ -110,10 +123,16 @@ export default function Today({
{data && data.todos.length > 0 && ( {data && data.todos.length > 0 && (
<Panel title="Todos"> <Panel title="Todos">
{team ? ( {team ? (
groupBy(data.todos, (todo) => todo.assigned_to.name).map(([name, todos]) => ( groupByStaff(data.todos, (todo) => todo.assigned_to).map((group) => (
<div key={name} className="mb-2 last:mb-0"> <div key={group.id ?? group.name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p> <p className="text-xs font-medium uppercase tracking-wide">
<TodoList todos={todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} /> <StaffName id={group.id} name={group.name} />
</p>
<TodoList
todos={group.rows}
onChanged={refresh}
open={{ onOpenBuyer, onOpenBusiness }}
/>
</div> </div>
)) ))
) : ( ) : (
@@ -125,11 +144,16 @@ export default function Today({
{data && data.follow_ups.length > 0 && ( {data && data.follow_ups.length > 0 && (
<Panel title="Follow-ups due"> <Panel title="Follow-ups due">
{team ? ( {team ? (
groupBy(data.follow_ups, (row) => row.created_by_name ?? 'unassigned').map( groupByStaff(data.follow_ups, (row) => ({
([name, rows]) => ( id: row.created_by_id,
<div key={name} className="mb-2 last:mb-0"> name: row.created_by_name ?? 'unassigned',
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p> })).map(
{rows.map((row) => ( (group) => (
<div key={group.id ?? group.name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide">
<StaffName id={group.id} name={group.name} />
</p>
{group.rows.map((row) => (
<FollowUpRow <FollowUpRow
key={row.id} key={row.id}
row={row} row={row}
@@ -278,7 +302,11 @@ function PendingNdaRow({
</button> </button>
, sent <span className="text-gray-500">{formatDay(row.sent_at)}</span> , sent <span className="text-gray-500">{formatDay(row.sent_at)}</span>
</span> </span>
{team && <span className="text-xs text-gray-500">{row.created_by_name ?? '—'}</span>} {team && (
<span className="text-xs">
<StaffName id={row.created_by_id} name={row.created_by_name ?? '—'} />
</span>
)}
</div> </div>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
type TodoKind, type TodoKind,
} from './api.js'; } from './api.js';
import { Dialog, formatDay, formatStamp } from './components.js'; import { Dialog, formatDay, formatStamp } from './components.js';
import { StaffName } from './staff-color.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm'; const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
@@ -80,8 +81,9 @@ function NoteRow({
{note.context.label} {note.context.label}
</span> </span>
)} )}
<span> <span className="inline-flex items-center gap-1">
{note.author?.name ?? 'unknown'} · {formatStamp(note.created_at)} <StaffName id={note.author?.id} name={note.author?.name} />·{' '}
{formatStamp(note.created_at)}
</span> </span>
<button <button
onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))} onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))}
@@ -281,7 +283,7 @@ export function TodoRow({
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400"> <div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{todo.assigned_to.name}</span> <StaffName id={todo.assigned_to.id} name={todo.assigned_to.name} />
{todo.due_at && ( {todo.due_at && (
<span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}> <span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}>
due {formatDay(todo.due_at)} due {formatDay(todo.due_at)}