actual state

This commit is contained in:
2026-07-29 14:50:11 -05:00
parent c9202d0b80
commit 96a37a495e
10 changed files with 561 additions and 117 deletions

View File

@@ -201,6 +201,7 @@ directory aborts the scan with an error naming the path.
| 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 | background: re-check pending NDAs + stale mirror rows | 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 |
| DELETE | /api/deals/:id | delete a deal; notes/todos cascade | 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 |
Everything except health, staff (GET+POST) and login requires the session Everything except health, staff (GET+POST) and login requires the session
@@ -307,6 +308,28 @@ it is never committed and never logged.
Only requests whose title starts with **`Buyer Forms - NDA`** are considered. Only requests whose title starts with **`Buyer Forms - NDA`** are considered.
**The template was renamed around 2026-06-29.** Requests created before that
are titled `Buyer Forms -<Name> - <Business>`, without the ` - NDA`. The
refresh therefore runs **two legs**:
| leg | query | when |
| --- | --- | --- |
| current | `title:"Buyer Forms - NDA" AND created:{<cutoff> TO *}` | always |
| pre-rename | `title:"Buyer Forms" AND created:{<cutoff> TO 2026-06-30}` | only when the window starts before `RENAME_DATE` |
Both feed the same upsert, so the inbox needs no notion of the two formats —
rows are rows. An incremental refresh never pays for the second leg: its
cutoff is days old, well past the rename.
The legacy query is deliberately **`Buyer Forms`, not `Buyer Forms -`**. The
API does not treat the trailing punctuation as part of the phrase: with the
hyphen it returns 70 requests for May/June, without it 535, and ~87% of the
wider set are genuine pre-rename NDAs. Precision comes from the code-side
safety net instead — `/^Buyer Forms -(?!.*NDA)/` plus a signer — which is why
the loose phrase is safe here. Unbounded it would not be: `title:"Buyer Forms"`
matches 11,877 of the account's 13,264 requests, so the date bound is what
makes it selective.
**The inbox is DB-backed.** Proxying the list endpoint on every view mount did **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 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 means 78 paged calls, ~74s of latency, throttling (Dropbox answers `409` as
@@ -317,7 +340,17 @@ are mirrored into `ds_request` by a background task, and
unaffected by tab switches. It returns the same per-request shape as before 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 (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`, `refresh_state` and `covers_from`.
* Filtering is **DB-side**: `?status=pending|signed|declined` and `?q=` over
signer name and e-mail. The per-status counts describe the whole matching
set (search applied, status not), so the chips stay meaningful while one is
active — the same rule the business and buyer lists follow.
* `covers_from` is how far back the mirror actually reaches. A walk stopped by
the `MAX_PAGES` cap covers less than it was asked for, so it records the
oldest date it got to and the inbox says "showing data from …" instead of
presenting a short list as complete. Coverage only ever widens: an
incremental walk reaching back two days does not un-mirror what a full reload
fetched last week.
* `POST /api/nda-inbox/refresh` starts the walk and returns `202` immediately, * `POST /api/nda-inbox/refresh` starts the walk and returns `202` immediately,
or `409` when one is already running — the slot is claimed with a conditional 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 upsert on `app_meta`, so two clicks cannot start two walks. A `running` state

View File

@@ -176,6 +176,7 @@ interface DealRow {
status: DealStatus; status: DealStatus;
follow_up_at: string | null; follow_up_at: string | null;
note_count: number; note_count: number;
todo_count: number;
business_id: string; business_id: string;
business_name: string; business_name: string;
business_status: string; business_status: string;
@@ -456,7 +457,10 @@ export function registerBuyerRoutes(app: FastifyInstance): void {
const deals = await query<DealRow>( const deals = await query<DealRow>(
`SELECT d.id, d.nda_id, d.status, d.follow_up_at, `SELECT d.id, d.nda_id, d.status, d.follow_up_at,
b.id AS business_id, b.name AS business_name, b.status AS business_status, b.id AS business_id, b.name AS business_name, b.status AS business_status,
(SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count (SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count,
-- Both counts feed the delete confirmation: the user should know
-- what disappears with the deal before agreeing to it.
(SELECT count(*)::int FROM todo WHERE deal_id = d.id) AS todo_count
FROM deal d JOIN business b ON b.id = d.business_id FROM deal d JOIN business b ON b.id = d.business_id
WHERE d.buyer_id = $1 ORDER BY d.created_at`, WHERE d.buyer_id = $1 ORDER BY d.created_at`,
[id], [id],
@@ -470,6 +474,7 @@ export function registerBuyerRoutes(app: FastifyInstance): void {
status: deal.status, status: deal.status,
follow_up_at: deal.follow_up_at, follow_up_at: deal.follow_up_at,
note_count: deal.note_count, note_count: deal.note_count,
todo_count: deal.todo_count,
business: { business: {
id: deal.business_id, id: deal.business_id,
name: deal.business_name, name: deal.business_name,
@@ -777,6 +782,19 @@ export function registerBuyerRoutes(app: FastifyInstance): void {
}, },
); );
/**
* Removes one deal. Its notes, todos and documents go with it through the
* ON DELETE CASCADE on their deal_id; the round and the buyer are untouched,
* which is what the confirmation in the UI promises.
*/
app.delete<{ Params: { id: string } }>('/api/deals/:id', async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'deal')) return reply;
const row = await queryOne<{ id: string }>('DELETE FROM deal WHERE id = $1 RETURNING id', [id]);
if (!row) return reply.code(404).send({ error: 'deal not found' });
return { ok: true };
});
/** Read-only; the full notes UI follows in module 5. */ /** Read-only; the full notes UI follows in module 5. */
app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => {
const { id } = req.params; const { id } = req.params;

View File

@@ -118,6 +118,59 @@ export function ndaSearchQuery(since: Date): string {
return `title:"${NDA_TITLE_PREFIX}" AND created:{${day} TO *}`; return `title:"${NDA_TITLE_PREFIX}" AND created:{${day} TO *}`;
} }
/**
* Before this date the NDA template was called plain `Buyer Forms -<Name> -
* <Business>`; it was renamed to add the ` - NDA` around 2026-06-29. Requests
* older than the rename therefore do not match NDA_TITLE_PREFIX at all, and
* are fetched by a second, date-bounded query instead.
*
* The bound is deliberately a day past the rename so nothing can slip through
* the seam; the small overlap re-reads a few requests, which is harmless
* because both legs feed the same idempotent upsert.
*/
export const RENAME_DATE = new Date('2026-06-30T00:00:00Z');
/**
* The phrase the legacy leg searches for.
*
* Deliberately without the trailing hyphen: `title:"Buyer Forms -"` matches
* only 70 requests in May/June where `title:"Buyer Forms"` matches 535, and
* ~87% of that wider set are genuine pre-rename NDAs. The API evidently does
* not treat the trailing punctuation as part of the phrase, so the hyphen
* costs recall and buys nothing. Precision is restored by
* isLegacyNdaRequest() below, which is what the safety net is for.
*/
export const LEGACY_TITLE_QUERY = 'Buyer Forms';
/**
* `title:"Buyer Forms"` on its own matches 11,877 of the account's 13,264
* requests, so it is useless unbounded. Restricting it to the period before
* the rename is what makes it selective — after that date the strict prefix
* covers everything anyway.
*/
export function legacyNdaSearchQuery(since: Date): string {
const from = since.toISOString().slice(0, 10);
const to = RENAME_DATE.toISOString().slice(0, 10);
return `title:"${LEGACY_TITLE_QUERY}" AND created:{${from} TO ${to}}`;
}
/**
* Safety net for the legacy leg: the old title, no "NDA" anywhere in it (that
* belongs to the other leg), and an actual signer. Anything else the loose
* query drags in is logged and skipped.
*
* The hyphen after "Buyer Forms" is optional because a handful of requests
* were typed without it ("Buyer Forms Jack Cahn - …") and are perfectly good
* NDAs. The trailing \S is what still rejects the bare "Buyer Forms" rows,
* which carry neither a name nor a business.
*/
export function isLegacyNdaRequest(request: SignatureRequest): boolean {
const title = request.title ?? '';
if (!/^Buyer Forms\s*-?\s*(?!.*NDA)\S/.test(title)) return false;
const signature = request.signatures?.[0];
return Boolean(signature?.signer_name?.trim() || signature?.signer_email_address?.trim());
}
/** /**
* 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.

View File

@@ -13,6 +13,7 @@ import {
statusOf, statusOf,
} from './dropbox-sign.js'; } from './dropbox-sign.js';
import { import {
META_COVERS_FROM,
META_LAST_REFRESH, META_LAST_REFRESH,
OVERLAP_MS, OVERLAP_MS,
REFRESH_KEYS, REFRESH_KEYS,
@@ -349,7 +350,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
* Reads ds_request only — never Dropbox. Mounting the view is one indexed * Reads ds_request only — never Dropbox. Mounting the view is one indexed
* query, so switching tabs costs nothing and the list is always there. * query, so switching tabs costs nothing and the list is always there.
*/ */
app.get<{ Querystring: { since?: string } }>('/api/nda-inbox', async (req, reply) => { app.get<{ Querystring: { since?: string; status?: string; q?: string } }>(
'/api/nda-inbox',
async (req, reply) => {
if (!isConfigured()) return notConfigured(reply); if (!isConfigured()) return notConfigured(reply);
const sinceParam = trimmed(req.query.since); const sinceParam = trimmed(req.query.since);
@@ -360,6 +363,37 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
return reply.code(400).send({ error: `invalid since: ${sinceParam}` }); return reply.code(400).send({ error: `invalid since: ${sinceParam}` });
} }
// Filtering happens here rather than in the browser: the mirror holds
// hundreds of rows and grows, and the status counts have to describe the
// whole matching set, not the slice that was shipped.
const status = trimmed(req.query.status);
if (status && !['pending', 'signed', 'declined'].includes(status)) {
return reply.code(400).send({ error: `invalid status: ${status}` });
}
const search = trimmed(req.query.q);
const baseParams: unknown[] = [since.toISOString()];
let baseWhere = 'WHERE r.created_at >= $1';
if (search) {
baseParams.push(`%${search}%`);
const p = `$${baseParams.length}`;
baseWhere += ` AND (r.signer_name ILIKE ${p} OR r.signer_email ILIKE ${p})`;
}
// Counts cover every status so the chips stay usable while one is active,
// exactly like the business and buyer lists.
const counts = await query<{ status: string; n: number }>(
`SELECT r.status, count(*)::int AS n FROM ds_request r ${baseWhere} GROUP BY r.status`,
baseParams,
);
const listParams = [...baseParams];
let listWhere = baseWhere;
if (status) {
listParams.push(status);
listWhere += ` AND r.status = $${listParams.length}`;
}
// Full timestamps, not calendar days: Dropbox orders strictly by date and // Full timestamps, not calendar days: Dropbox orders strictly by date and
// time, and dropping the time made same-day rows look arbitrarily ordered. // time, and dropping the time made same-day rows look arbitrarily ordered.
const rows = await query<{ const rows = await query<{
@@ -389,9 +423,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
n.id AS nda_id, n.buyer_id n.id AS nda_id, n.buyer_id
FROM ds_request r FROM ds_request r
LEFT JOIN nda n ON n.dropbox_sign_id = r.signature_request_id LEFT JOIN nda n ON n.dropbox_sign_id = r.signature_request_id
WHERE r.created_at >= $1 ${listWhere}
ORDER BY coalesce(r.signed_at, r.created_at) DESC, r.signature_request_id DESC`, ORDER BY coalesce(r.signed_at, r.created_at) DESC, r.signature_request_id DESC`,
[since.toISOString()], listParams,
); );
const emails = [ const emails = [
@@ -445,8 +479,15 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
readTaskState(REFRESH_KEYS), readTaskState(REFRESH_KEYS),
readTaskState(SYNC_KEYS), readTaskState(SYNC_KEYS),
]); ]);
const coversFrom = await getMeta(META_COVERS_FROM);
return { return {
requests, requests,
counts: {
pending: counts.find((c) => c.status === 'pending')?.n ?? 0,
signed: counts.find((c) => c.status === 'signed')?.n ?? 0,
declined: counts.find((c) => c.status === 'declined')?.n ?? 0,
all: counts.reduce((sum, c) => sum + c.n, 0),
},
last_refresh_at: refresh.last_at, last_refresh_at: refresh.last_at,
refresh_state: refresh.state, refresh_state: refresh.state,
/** {pages, seen, stored} of the last completed walk, null before the first. */ /** {pages, seen, stored} of the last completed walk, null before the first. */
@@ -454,8 +495,12 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
last_sync_at: sync.last_at, last_sync_at: sync.last_at,
sync_state: sync.state, sync_state: sync.state,
last_sync_result: sync.last_result, last_sync_result: sync.last_result,
/** How far back the mirror actually reaches; the UI warns when it is later
* than the requested window. */
covers_from: coversFrom,
}; };
}); },
);
/** /**
* Starts the background walk. 202 when it was started, 409 when one is * Starts the background walk. 202 when it was started, 409 when one is

View File

@@ -2,13 +2,16 @@ import type { FastifyBaseLogger } from 'fastify';
import { withTransaction } from './db.js'; import { withTransaction } from './db.js';
import { import {
NDA_TITLE_PREFIX, NDA_TITLE_PREFIX,
RENAME_DATE,
type SignatureRequest, type SignatureRequest,
isLegacyNdaRequest,
isRateLimited, isRateLimited,
legacyNdaSearchQuery,
listPage, listPage,
ndaSearchQuery, ndaSearchQuery,
statusOf, statusOf,
} from './dropbox-sign.js'; } from './dropbox-sign.js';
import { type TaskKeys, clearStaleTask, getMeta, startTask } from './background-task.js'; import { type TaskKeys, clearStaleTask, getMeta, setMeta, 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
@@ -35,6 +38,14 @@ export const REFRESH_KEYS: TaskKeys = {
}; };
export const META_LAST_REFRESH = REFRESH_KEYS.lastAt; export const META_LAST_REFRESH = REFRESH_KEYS.lastAt;
/**
* The earliest date the mirror is known to be complete from. Normally this is
* just the widest window ever walked, but a walk stopped by MAX_PAGES covers
* less than it was asked for, and the inbox has to say so rather than quietly
* show a short list.
*/
export const META_COVERS_FROM = 'ds_mirror_covers_from';
/** /**
* How far back a follow-up walk reaches beyond the previous refresh. Generous * 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 * on purpose: a request created just before the last run, or a clock skew
@@ -89,6 +100,24 @@ export interface RefreshResult {
pages: number; pages: number;
stored: number; stored: number;
seen: number; seen: number;
/** Of `stored`, how many came from the pre-rename title format. */
legacy_stored: number;
/** Earliest creation date this walk actually reached, ISO date. */
covers_from: string;
/** True when MAX_PAGES ended the walk before it reached the cutoff. */
truncated: boolean;
}
interface LegResult {
pages: number;
seen: number;
stored: number;
/** Rows the query returned that the safety net rejected. */
skipped: number;
/** Oldest created_at seen, unix seconds; Infinity when nothing came back. */
oldest: number;
/** False when MAX_PAGES cut it short. */
complete: boolean;
} }
/** Fetches one page, backing off and retrying while Dropbox throttles us. */ /** Fetches one page, backing off and retrying while Dropbox throttles us. */
@@ -140,59 +169,146 @@ export async function refreshCutoff(since: Date, explicitSince: boolean): Promis
} }
/** /**
* Walks the NDA requests created since `cutoffAt` and mirrors them. * Pages through one search, upserting what survives the safety net.
* *
* The filtering happens server-side via the search query, so the pages this * The filtering happens server-side via the query, so the pages this walks are
* walks are already ours: `seen` and `stored` should come out roughly equal. * already ours; `accept` is the belt-and-braces check that keeps the mirror
* The two client-side filters below are safety nets — if the query were ever * correct if a query is ever ignored or widened, and says so in the log.
* ignored or changed, they keep the mirror correct and say so in the log.
*/ */
async function walk(cutoffAt: Date, log: FastifyBaseLogger): Promise<RefreshResult> { async function walkLeg(
const cutoff = Math.floor(cutoffAt.getTime() / 1000); label: string,
const query = ndaSearchQuery(cutoffAt); query: string,
const loggedBody = { done: false }; cutoff: number,
accept: (request: SignatureRequest) => boolean,
log: FastifyBaseLogger,
loggedBody: { done: boolean },
): Promise<LegResult> {
let stored = 0; let stored = 0;
let seen = 0; let seen = 0;
let foreign = 0; let skipped = 0;
let oldest = Number.POSITIVE_INFINITY;
let complete = false;
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, query, log, loggedBody); const result = await fetchPageWithBackoff(page, query, log, loggedBody);
if (result.requests.length === 0) break; if (result.requests.length === 0) {
complete = true;
break;
}
seen += result.requests.length; seen += result.requests.length;
// Safety net 1: a title the search should have excluded. const wanted = result.requests.filter(accept);
const wrongTitle = result.requests.length - result.ndaRequests.length; const rejected = result.requests.length - wanted.length;
if (wrongTitle > 0) { if (rejected > 0) {
foreign += wrongTitle; if (skipped === 0) {
if (foreign === wrongTitle) { const example = result.requests.find((request) => !accept(request));
const example = result.requests.find(
(request) => !request.title?.startsWith(NDA_TITLE_PREFIX),
);
log.warn( log.warn(
`[nda-refresh] the search returned ${wrongTitle} request(s) with a foreign title on ` + `[nda-refresh] ${label}: the search returned ${rejected} request(s) this leg does not ` +
`page ${page}filtering them out here; e.g. ${JSON.stringify(example?.title ?? '')}`, `accept on page ${page}skipping them; e.g. ${JSON.stringify(example?.title ?? '')}`,
); );
} }
skipped += rejected;
} }
// Safety net 2: the date clause is day-granular, so a row from the cutoff // The date clause is day-granular, so a row from the cutoff day itself can
// day itself can come back slightly too old. // come back slightly too old.
const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff); const inWindow = wanted.filter((request) => request.created_at >= cutoff);
await upsertRequests(inWindow); await upsertRequests(inWindow);
stored += inWindow.length; stored += inWindow.length;
if (result.page >= result.numPages) break; for (const request of result.requests) {
if (request.created_at < oldest) oldest = request.created_at;
}
if (result.page >= result.numPages) {
complete = true;
break;
}
} }
const pages = Math.min(page, MAX_PAGES); return { pages: Math.min(page, MAX_PAGES), seen, stored, skipped, oldest, complete };
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)` : ''), * Mirrors every NDA request created since `cutoffAt`.
*
* Two legs, because the Dropbox template was renamed on ~2026-06-29: the
* current `Buyer Forms - NDA …` title, and — only when the window reaches
* before the rename — the older `Buyer Forms -…` one, bounded to the period
* where that looser prefix is still selective.
*/
async function walk(cutoffAt: Date, log: FastifyBaseLogger): Promise<RefreshResult> {
const cutoff = Math.floor(cutoffAt.getTime() / 1000);
const loggedBody = { done: false };
const current = await walkLeg(
'current format',
ndaSearchQuery(cutoffAt),
cutoff,
(request) => Boolean(request.title?.startsWith(NDA_TITLE_PREFIX)),
log,
loggedBody,
); );
return { pages, stored, seen };
const needsLegacy = cutoffAt < RENAME_DATE;
const legacy = needsLegacy
? await walkLeg(
'pre-rename format',
legacyNdaSearchQuery(cutoffAt),
cutoff,
isLegacyNdaRequest,
log,
loggedBody,
)
: null;
const pages = current.pages + (legacy?.pages ?? 0);
const seen = current.seen + (legacy?.seen ?? 0);
const stored = current.stored + (legacy?.stored ?? 0);
const complete = current.complete && (legacy?.complete ?? true);
const oldest = Math.min(current.oldest, legacy?.oldest ?? Number.POSITIVE_INFINITY);
// A complete walk covers everything back to its cutoff. A truncated one only
// covers back to the oldest row it managed to read.
const reached = complete || !Number.isFinite(oldest) ? cutoffAt : new Date(oldest * 1000);
if (!complete) {
log.warn(
`[nda-refresh] stopped at the ${MAX_PAGES}-page cap before reaching ` +
`${cutoffAt.toISOString().slice(0, 10)} — the mirror only covers back to ` +
`${reached.toISOString().slice(0, 10)}`,
);
}
await widenCoverage(reached);
log.info(
`[nda-refresh] walked ${pages} page(s) since ${cutoffAt.toISOString().slice(0, 10)}, ` +
`saw ${seen}, stored ${stored}` +
(legacy ? ` (${legacy.stored} pre-rename)` : '') +
(current.skipped + (legacy?.skipped ?? 0) > 0
? `, skipped ${current.skipped + (legacy?.skipped ?? 0)} non-matching`
: ''),
);
return {
pages,
stored,
seen,
legacy_stored: legacy?.stored ?? 0,
covers_from: reached.toISOString().slice(0, 10),
truncated: !complete,
};
}
/**
* Coverage only ever improves: an incremental walk reaching back two days does
* not un-mirror what a full reload fetched last week.
*/
async function widenCoverage(reached: Date): Promise<void> {
const current = await getMeta(META_COVERS_FROM);
const currentAt = current ? new Date(current) : null;
if (currentAt && !Number.isNaN(currentAt.getTime()) && currentAt <= reached) return;
await setMeta(META_COVERS_FROM, reached.toISOString());
} }
/** /**

View File

@@ -113,9 +113,10 @@ export default function App() {
</main> </main>
) : ( ) : (
<main <main
// The inbox table carries eight columns and needs the extra width. // The inbox table carries eight columns, and the buyer detail is a
// two-column grid — both need more than the default reading width.
className={`mx-auto w-full flex-1 overflow-auto px-6 py-6 ${ 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 === 'nda-inbox' || route.view === 'buyer' ? 'max-w-7xl' : 'max-w-5xl'
}`} }`}
> >
{route.view === 'today' && ( {route.view === 'today' && (

View File

@@ -92,6 +92,7 @@ export interface Deal {
status: DealStatus; status: DealStatus;
follow_up_at: Day | null; follow_up_at: Day | null;
note_count: number; note_count: number;
todo_count: number;
business: { id: string; name: string; status: BusinessStatus }; business: { id: string; name: string; status: BusinessStatus };
} }
@@ -287,10 +288,21 @@ export interface RefreshResult {
pages: number; pages: number;
seen: number; seen: number;
stored: number; stored: number;
/** Of `stored`, how many used the pre-rename title format. */
legacy_stored: number;
}
export interface InboxCounts {
pending: number;
signed: number;
declined: number;
all: number;
} }
export interface Inbox { export interface Inbox {
requests: InboxRow[]; requests: InboxRow[];
/** Per status over the whole matching set, not just the rows shipped. */
counts: InboxCounts;
/** 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;
@@ -300,6 +312,8 @@ export interface Inbox {
last_sync_at: string | null; last_sync_at: string | null;
sync_state: RefreshState; sync_state: RefreshState;
last_sync_result: SyncResult | null; last_sync_result: SyncResult | null;
/** ISO date the mirror actually reaches back to, null if never walked. */
covers_from: string | null;
} }
export interface ImportResult { export interface ImportResult {
@@ -370,6 +384,8 @@ const qs = (params: Record<string, string | undefined>) =>
export const api = { export const api = {
me: () => request<Staff>('/api/me'), me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'), staff: () => request<Staff[]>('/api/staff'),
/** An existing name is reactivated rather than rejected. */
createStaff: (name: string) => post<Staff>('/api/staff', { name }),
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }), login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
logout: () => post<{ ok: boolean }>('/api/logout'), logout: () => post<{ ok: boolean }>('/api/logout'),
businesses: (status: BusinessStatus | '', search: string) => businesses: (status: BusinessStatus | '', search: string) =>
@@ -401,6 +417,7 @@ export const api = {
patch<NdaRound>(`/api/ndas/${id}`, body), patch<NdaRound>(`/api/ndas/${id}`, body),
addDeal: (ndaId: string, businessId: string) => addDeal: (ndaId: string, businessId: string) =>
post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }), post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }),
deleteDeal: (id: string) => del<{ ok: boolean }>(`/api/deals/${id}`),
setDealStatus: (id: string, status: DealStatus, comment?: string) => setDealStatus: (id: string, status: DealStatus, comment?: string) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>( post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${id}/status`, `/api/deals/${id}/status`,
@@ -439,7 +456,8 @@ 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, status?: InboxStatus | '', q?: string) =>
request<Inbox>(`/api/nda-inbox?${qs({ since, status, q })}`),
/** /**
* Starts the background walk; resolves as soon as it is queued. Passing * 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 * `since` makes the server re-read that whole window — leave it out for the

View File

@@ -167,75 +167,85 @@ export default function BuyerDetail({
</Dialog> </Dialog>
)} )}
<Panel title="Identity"> {/* Two columns from lg up: who they are on the left, what is happening
<div className="grid grid-cols-2 gap-x-6 gap-y-3"> with them on the right. Below lg it collapses to the old single
<InlineField column. min-w-0 on both so long values wrap instead of forcing the
label="Company name" page to scroll sideways. */}
value={buyer.company_name} <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))} <div className="flex min-w-0 flex-col gap-4">
/> <Panel title="Identity">
<InlineField <div className="grid grid-cols-2 gap-x-6 gap-y-3">
label="State" <InlineField
value={buyer.state} label="Company name"
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))} value={buyer.company_name}
/> onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
<InlineField />
label="Address" <InlineField
value={buyer.address} label="State"
multiline value={buyer.state}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))} onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
/> />
<InlineField <InlineField
label="How they heard about us" label="Address"
value={buyer.how_heard} value={buyer.address}
multiline multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))} onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
/> />
<div className="col-span-2"> <InlineField
<InlineField label="How they heard about us"
label="Background / experience" value={buyer.how_heard}
value={buyer.background_experience} multiline
multiline onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
onSave={(next) => />
guard(() => api.updateBuyer(buyer.id, { background_experience: next })) <div className="col-span-2">
} <InlineField
/> label="Background / experience"
</div> value={buyer.background_experience}
<div className="col-span-2"> multiline
<TriState onSave={(next) =>
label="Interested in updates" guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
value={buyer.interested_in_updates} }
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))} />
/> </div>
</div> <div className="col-span-2">
<TriState
label="Interested in updates"
value={buyer.interested_in_updates}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))}
/>
</div>
</div>
</Panel>
<Contacts buyer={buyer} guard={guard} />
{/* Everything the buyer ever produced, including their rounds and deals. */}
<Panel title="Notes">
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} />
</Panel>
</div> </div>
</Panel>
<Contacts buyer={buyer} guard={guard} /> <div className="flex min-w-0 flex-col gap-4">
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
noteEpoch={noteEpoch}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
</div>
</Panel>
{/* Everything the buyer ever produced, including their rounds and deals. */} <Panel title="Todos">
<Panel title="Notes"> <TodosPanel target={{ buyer_id: buyer.id }} />
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} /> </Panel>
</Panel>
<Panel title="Todos">
<TodosPanel target={{ buyer_id: buyer.id }} />
</Panel>
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
noteEpoch={noteEpoch}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
</div> </div>
</Panel> </div>
</div> </div>
); );
} }
@@ -517,6 +527,7 @@ function DealRow({
}) { }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [pending, setPending] = useState<DealStatus | null>(null); const [pending, setPending] = useState<DealStatus | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
@@ -583,6 +594,16 @@ function DealRow({
{DEAL_LABELS[status]} {DEAL_LABELS[status]}
</button> </button>
))} ))}
<div className="my-1 border-t border-gray-100" />
<button
onClick={() => {
setMenuOpen(false);
setConfirmDelete(true);
}}
className="block w-full px-3 py-1 text-left text-sm text-red-600 hover:bg-red-50"
>
Delete deal
</button>
</div> </div>
)} )}
</div> </div>
@@ -603,6 +624,27 @@ function DealRow({
)} )}
</div> </div>
{confirmDelete && (
<Dialog
title="Delete this deal?"
confirmLabel="Delete"
busy={busy}
onCancel={() => setConfirmDelete(false)}
onConfirm={async () => {
setBusy(true);
await guard(() => api.deleteDeal(deal.id));
setBusy(false);
setConfirmDelete(false);
}}
>
<p className="text-sm text-gray-600">
This removes <span className="font-medium">{deal.business.name}</span> and its{' '}
{deal.note_count} note{deal.note_count === 1 ? '' : 's'} and {deal.todo_count} todo
{deal.todo_count === 1 ? '' : 's'}. The NDA round and the buyer stay.
</p>
</Dialog>
)}
{pending && ( {pending && (
<Dialog <Dialog
title={DEAL_ACTIONS[pending]} title={DEAL_ACTIONS[pending]}

View File

@@ -1,16 +1,26 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { api, type Staff } from '../api.js'; import { api, type Staff } from '../api.js';
import { StaffName } from '../staff-color.js';
export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) { export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) {
const [staff, setStaff] = useState<Staff[]>([]); const [staff, setStaff] = useState<Staff[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(
() =>
api
.staff()
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message)),
[],
);
useEffect(() => { useEffect(() => {
api void load();
.staff() }, [load]);
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message));
}, []);
async function pick(member: Staff) { async function pick(member: Staff) {
try { try {
@@ -21,6 +31,28 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void })
} }
} }
/**
* Adding a person here means a new colleague can sign in without anyone
* running curl. An existing name is not an error: the endpoint reactivates
* that person, which is exactly what you want when someone comes back.
*/
async function add() {
const trimmed = name.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
await api.createStaff(trimmed);
setName('');
setAdding(false);
await load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50"> <div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-80 rounded-lg border border-gray-200 bg-white p-6 shadow-sm"> <div className="w-80 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
@@ -33,13 +65,45 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void })
className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100" className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => pick(member)} onClick={() => pick(member)}
> >
{member.name} <StaffName id={member.id} name={member.name} />
</button> </button>
))} ))}
{staff.length === 0 && !error && ( {staff.length === 0 && !error && (
<p className="text-sm text-gray-500">No staff members yet.</p> <p className="text-sm text-gray-500">No staff members yet.</p>
)} )}
</div> </div>
<div className="mt-4 border-t border-gray-200 pt-3">
{adding ? (
<div className="flex gap-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void add();
if (e.key === 'Escape') setAdding(false);
}}
placeholder="Name"
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
<button
onClick={add}
disabled={busy || name.trim() === ''}
className="rounded bg-gray-900 px-3 py-1 text-sm text-white disabled:opacity-40"
>
Add
</button>
</div>
) : (
<button
onClick={() => setAdding(true)}
className="text-sm text-blue-600 hover:underline"
>
Add person
</button>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -41,6 +41,10 @@ export default function NdaInbox({
// minute of paging. A plain Refresh must not do that — only a date the user // 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. // actually moved since the last run asks for the wide reload, once.
const [sinceChanged, setSinceChanged] = useState(false); const [sinceChanged, setSinceChanged] = useState(false);
const [status, setStatus] = useState<InboxStatus | ''>('');
// Typed straight into the box; `search` is the debounced value that queries.
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
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);
@@ -66,11 +70,18 @@ export default function NdaInbox({
.find((state) => state?.startsWith('error:')) .find((state) => state?.startsWith('error:'))
?.slice('error:'.length); ?.slice('error:'.length);
// Debounce the search box: filtering is a DB query, not a client-side pass
// over rows that happen to be loaded.
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(timer);
}, [searchInput]);
/** 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 () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.ndaInbox(since); const res = await api.ndaInbox(since, status, search);
setInbox(res); setInbox(res);
setError(null); setError(null);
// Deliberately no preselection: a suggestion is a guess, and an import // Deliberately no preselection: a suggestion is a guess, and an import
@@ -83,7 +94,7 @@ export default function NdaInbox({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [since]); }, [since, status, search]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -94,10 +105,12 @@ export default function NdaInbox({
useEffect(() => { useEffect(() => {
if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) { if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
watchingRefresh.current = false; watchingRefresh.current = false;
const { pages, seen, stored } = inbox.last_refresh_result; const { pages, seen, stored, legacy_stored: legacy } = inbox.last_refresh_result;
setRefreshNotice( setRefreshNotice(
`Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` + `Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` +
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`, `from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}` +
(legacy > 0 ? ` · ${legacy} in the pre-rename title format` : '') +
'.',
); );
} }
if (!syncing && watchingSync.current && inbox?.last_sync_result) { if (!syncing && watchingSync.current && inbox?.last_sync_result) {
@@ -241,6 +254,47 @@ export default function NdaInbox({
{refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>} {refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>}
{syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</p>} {syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</p>}
{/* The mirror can reach back less far than the picker asks for — say so
rather than showing a short list as if it were complete. */}
{inbox?.covers_from && inbox.covers_from.slice(0, 10) > since && (
<p className="mb-3 text-sm text-amber-700">
Showing data from {formatDayTimeParts(inbox.covers_from).day} the mirror does not reach
back to {formatDayTimeParts(since).day} yet. Change the date and press Reload window to
fetch the rest.
</p>
)}
<div className="mb-3 flex flex-wrap items-center gap-3">
<div className="flex gap-1">
{(
[
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'signed', label: 'Signed' },
{ value: 'declined', label: 'Declined' },
] as { value: InboxStatus | ''; label: string }[]
).map((chip) => (
<button
key={chip.label}
onClick={() => setStatus(chip.value)}
className={`rounded px-3 py-1.5 text-sm ${
status === chip.value
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{chip.label} ({chip.value === '' ? (inbox?.counts.all ?? 0) : (inbox?.counts[chip.value] ?? 0)})
</button>
))}
</div>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search signer or e-mail…"
className={`${INPUT} w-64`}
/>
</div>
{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>}