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/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 7–8 paged calls, ~74s of latency, throttling (Dropbox answers `409` as
|
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
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user