actual state
This commit is contained in:
35
README.md
35
README.md
@@ -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/sync | background: re-check pending NDAs + stale mirror rows | 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 |
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
not survive contact with the real account: 700+ requests in a 90-day window
|
||||
means 7–8 paged calls, ~74s of latency, throttling (Dropbox answers `409` as
|
||||
@@ -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
|
||||
(status, signer, `imported`, `known_buyer` by exact normalised e-mail, up to
|
||||
five `business_suggestions` by word overlap with the title remainder) plus
|
||||
`last_refresh_at` and `refresh_state`.
|
||||
`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,
|
||||
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
|
||||
|
||||
@@ -176,6 +176,7 @@ interface DealRow {
|
||||
status: DealStatus;
|
||||
follow_up_at: string | null;
|
||||
note_count: number;
|
||||
todo_count: number;
|
||||
business_id: string;
|
||||
business_name: string;
|
||||
business_status: string;
|
||||
@@ -456,7 +457,10 @@ export function registerBuyerRoutes(app: FastifyInstance): void {
|
||||
const deals = await query<DealRow>(
|
||||
`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,
|
||||
(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
|
||||
WHERE d.buyer_id = $1 ORDER BY d.created_at`,
|
||||
[id],
|
||||
@@ -470,6 +474,7 @@ export function registerBuyerRoutes(app: FastifyInstance): void {
|
||||
status: deal.status,
|
||||
follow_up_at: deal.follow_up_at,
|
||||
note_count: deal.note_count,
|
||||
todo_count: deal.todo_count,
|
||||
business: {
|
||||
id: deal.business_id,
|
||||
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. */
|
||||
app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -118,6 +118,59 @@ export function ndaSearchQuery(since: Date): string {
|
||||
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
|
||||
* (see nda-refresh.ts) so it can pace the calls and handle throttling.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
statusOf,
|
||||
} from './dropbox-sign.js';
|
||||
import {
|
||||
META_COVERS_FROM,
|
||||
META_LAST_REFRESH,
|
||||
OVERLAP_MS,
|
||||
REFRESH_KEYS,
|
||||
@@ -349,7 +350,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
|
||||
* Reads ds_request only — never Dropbox. Mounting the view is one indexed
|
||||
* query, so switching tabs costs nothing and the list is always there.
|
||||
*/
|
||||
app.get<{ Querystring: { since?: string } }>('/api/nda-inbox', async (req, reply) => {
|
||||
app.get<{ Querystring: { since?: string; status?: string; q?: string } }>(
|
||||
'/api/nda-inbox',
|
||||
async (req, reply) => {
|
||||
if (!isConfigured()) return notConfigured(reply);
|
||||
|
||||
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}` });
|
||||
}
|
||||
|
||||
// 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
|
||||
// time, and dropping the time made same-day rows look arbitrarily ordered.
|
||||
const rows = await query<{
|
||||
@@ -389,9 +423,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
|
||||
n.id AS nda_id, n.buyer_id
|
||||
FROM ds_request r
|
||||
LEFT JOIN nda n ON n.dropbox_sign_id = r.signature_request_id
|
||||
WHERE r.created_at >= $1
|
||||
${listWhere}
|
||||
ORDER BY coalesce(r.signed_at, r.created_at) DESC, r.signature_request_id DESC`,
|
||||
[since.toISOString()],
|
||||
listParams,
|
||||
);
|
||||
|
||||
const emails = [
|
||||
@@ -445,8 +479,15 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void {
|
||||
readTaskState(REFRESH_KEYS),
|
||||
readTaskState(SYNC_KEYS),
|
||||
]);
|
||||
const coversFrom = await getMeta(META_COVERS_FROM);
|
||||
return {
|
||||
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,
|
||||
refresh_state: refresh.state,
|
||||
/** {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,
|
||||
sync_state: sync.state,
|
||||
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
|
||||
|
||||
@@ -2,13 +2,16 @@ import type { FastifyBaseLogger } from 'fastify';
|
||||
import { withTransaction } from './db.js';
|
||||
import {
|
||||
NDA_TITLE_PREFIX,
|
||||
RENAME_DATE,
|
||||
type SignatureRequest,
|
||||
isLegacyNdaRequest,
|
||||
isRateLimited,
|
||||
legacyNdaSearchQuery,
|
||||
listPage,
|
||||
ndaSearchQuery,
|
||||
statusOf,
|
||||
} 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
|
||||
@@ -35,6 +38,14 @@ export const REFRESH_KEYS: TaskKeys = {
|
||||
};
|
||||
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
|
||||
* on purpose: a request created just before the last run, or a clock skew
|
||||
@@ -89,6 +100,24 @@ export interface RefreshResult {
|
||||
pages: number;
|
||||
stored: 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. */
|
||||
@@ -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
|
||||
* 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.
|
||||
* The filtering happens server-side via the query, so the pages this walks are
|
||||
* already ours; `accept` is the belt-and-braces check that keeps the mirror
|
||||
* correct if a query is ever ignored or widened, and says 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 };
|
||||
async function walkLeg(
|
||||
label: string,
|
||||
query: string,
|
||||
cutoff: number,
|
||||
accept: (request: SignatureRequest) => boolean,
|
||||
log: FastifyBaseLogger,
|
||||
loggedBody: { done: boolean },
|
||||
): Promise<LegResult> {
|
||||
let stored = 0;
|
||||
let seen = 0;
|
||||
let foreign = 0;
|
||||
let skipped = 0;
|
||||
let oldest = Number.POSITIVE_INFINITY;
|
||||
let complete = false;
|
||||
let page = 1;
|
||||
|
||||
for (; page <= MAX_PAGES; page += 1) {
|
||||
if (page > 1) await sleep(PAGE_PAUSE_MS);
|
||||
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;
|
||||
|
||||
// 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),
|
||||
);
|
||||
const wanted = result.requests.filter(accept);
|
||||
const rejected = result.requests.length - wanted.length;
|
||||
if (rejected > 0) {
|
||||
if (skipped === 0) {
|
||||
const example = result.requests.find((request) => !accept(request));
|
||||
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 ?? '')}`,
|
||||
`[nda-refresh] ${label}: the search returned ${rejected} request(s) this leg does not ` +
|
||||
`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
|
||||
// day itself can come back slightly too old.
|
||||
const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff);
|
||||
// The date clause is day-granular, so a row from the cutoff day itself can
|
||||
// come back slightly too old.
|
||||
const inWindow = wanted.filter((request) => request.created_at >= cutoff);
|
||||
await upsertRequests(inWindow);
|
||||
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);
|
||||
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: Math.min(page, MAX_PAGES), seen, stored, skipped, oldest, complete };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,9 +113,10 @@ export default function App() {
|
||||
</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 ${
|
||||
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' && (
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface Deal {
|
||||
status: DealStatus;
|
||||
follow_up_at: Day | null;
|
||||
note_count: number;
|
||||
todo_count: number;
|
||||
business: { id: string; name: string; status: BusinessStatus };
|
||||
}
|
||||
|
||||
@@ -287,10 +288,21 @@ export interface RefreshResult {
|
||||
pages: number;
|
||||
seen: 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 {
|
||||
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. */
|
||||
last_refresh_at: string | null;
|
||||
refresh_state: RefreshState;
|
||||
@@ -300,6 +312,8 @@ export interface Inbox {
|
||||
last_sync_at: string | null;
|
||||
sync_state: RefreshState;
|
||||
last_sync_result: SyncResult | null;
|
||||
/** ISO date the mirror actually reaches back to, null if never walked. */
|
||||
covers_from: string | null;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
@@ -370,6 +384,8 @@ const qs = (params: Record<string, string | undefined>) =>
|
||||
export const api = {
|
||||
me: () => request<Staff>('/api/me'),
|
||||
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 }),
|
||||
logout: () => post<{ ok: boolean }>('/api/logout'),
|
||||
businesses: (status: BusinessStatus | '', search: string) =>
|
||||
@@ -401,6 +417,7 @@ export const api = {
|
||||
patch<NdaRound>(`/api/ndas/${id}`, body),
|
||||
addDeal: (ndaId: string, businessId: string) =>
|
||||
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) =>
|
||||
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
|
||||
`/api/deals/${id}/status`,
|
||||
@@ -439,7 +456,8 @@ export const api = {
|
||||
}),
|
||||
|
||||
/** 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
|
||||
* `since` makes the server re-read that whole window — leave it out for the
|
||||
|
||||
@@ -167,75 +167,85 @@ export default function BuyerDetail({
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Panel title="Identity">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<InlineField
|
||||
label="Company name"
|
||||
value={buyer.company_name}
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="State"
|
||||
value={buyer.state}
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="Address"
|
||||
value={buyer.address}
|
||||
multiline
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="How they heard about us"
|
||||
value={buyer.how_heard}
|
||||
multiline
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<InlineField
|
||||
label="Background / experience"
|
||||
value={buyer.background_experience}
|
||||
multiline
|
||||
onSave={(next) =>
|
||||
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{/* Two columns from lg up: who they are on the left, what is happening
|
||||
with them on the right. Below lg it collapses to the old single
|
||||
column. min-w-0 on both so long values wrap instead of forcing the
|
||||
page to scroll sideways. */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel title="Identity">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<InlineField
|
||||
label="Company name"
|
||||
value={buyer.company_name}
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="State"
|
||||
value={buyer.state}
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="Address"
|
||||
value={buyer.address}
|
||||
multiline
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="How they heard about us"
|
||||
value={buyer.how_heard}
|
||||
multiline
|
||||
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<InlineField
|
||||
label="Background / experience"
|
||||
value={buyer.background_experience}
|
||||
multiline
|
||||
onSave={(next) =>
|
||||
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</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="Notes">
|
||||
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} />
|
||||
</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>}
|
||||
<Panel title="Todos">
|
||||
<TodosPanel target={{ buyer_id: buyer.id }} />
|
||||
</Panel>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -517,6 +527,7 @@ function DealRow({
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [pending, setPending] = useState<DealStatus | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
@@ -583,6 +594,16 @@ function DealRow({
|
||||
{DEAL_LABELS[status]}
|
||||
</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>
|
||||
@@ -603,6 +624,27 @@ function DealRow({
|
||||
)}
|
||||
</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 && (
|
||||
<Dialog
|
||||
title={DEAL_ACTIONS[pending]}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, type Staff } from '../api.js';
|
||||
import { StaffName } from '../staff-color.js';
|
||||
|
||||
export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) {
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
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(() => {
|
||||
api
|
||||
.staff()
|
||||
.then((list) => setStaff(list.filter((s) => s.active)))
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}, []);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function pick(member: Staff) {
|
||||
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 (
|
||||
<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">
|
||||
@@ -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"
|
||||
onClick={() => pick(member)}
|
||||
>
|
||||
{member.name}
|
||||
<StaffName id={member.id} name={member.name} />
|
||||
</button>
|
||||
))}
|
||||
{staff.length === 0 && !error && (
|
||||
<p className="text-sm text-gray-500">No staff members yet.</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,10 @@ export default function NdaInbox({
|
||||
// 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 [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 [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -66,11 +70,18 @@ export default function NdaInbox({
|
||||
.find((state) => state?.startsWith('error:'))
|
||||
?.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. */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.ndaInbox(since);
|
||||
const res = await api.ndaInbox(since, status, search);
|
||||
setInbox(res);
|
||||
setError(null);
|
||||
// Deliberately no preselection: a suggestion is a guess, and an import
|
||||
@@ -83,7 +94,7 @@ export default function NdaInbox({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [since]);
|
||||
}, [since, status, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -94,10 +105,12 @@ export default function NdaInbox({
|
||||
useEffect(() => {
|
||||
if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
|
||||
watchingRefresh.current = false;
|
||||
const { pages, seen, stored } = inbox.last_refresh_result;
|
||||
const { pages, seen, stored, legacy_stored: legacy } = inbox.last_refresh_result;
|
||||
setRefreshNotice(
|
||||
`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) {
|
||||
@@ -241,6 +254,47 @@ export default function NdaInbox({
|
||||
|
||||
{refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</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>}
|
||||
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user