Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion apps/api/src/approval-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ afterEach(() => {
});

interface Queue {
recommendations: { id: string; action: string }[];
recommendations: { id: string; action: string; bucket: string }[];
counts: { all: number; ready: number; needs_draft: number; research: number };
filter: string;
}
Expand Down Expand Up @@ -141,6 +141,50 @@ describe('filtering the approval queue', () => {
expect(counts.all).toBe(counts.ready + counts.needs_draft + counts.research);
});

/**
* The page fetches `all` once and the tabs pick from it in the browser, so
* the bucket on each row is what the tabs actually sort by. If it ever
* disagreed with the counts the tabs would show a number next to a list
* that contradicts it.
*/
test('every card names its bucket, and the buckets add up to the counts', async () => {
const { app, db } = await harness('filter-buckets');
await add(db, 'rec_research_1', 'refresh_research', 'website', false);
await add(db, 'rec_research_2', 'refresh_research', 'website', false);
await add(db, 'rec_email_undrafted', 'send_email', 'email', false);
await add(db, 'rec_email_drafted', 'send_email', 'email', true);

const { recommendations, counts } = await queue(app, 'all');
const bucketOf = (id: string) => recommendations.find((r) => r.id === id)?.bucket;

expect(bucketOf('rec_research_1')).toBe('research');
expect(bucketOf('rec_email_undrafted')).toBe('needs_draft');
expect(bucketOf('rec_email_drafted')).toBe('ready');

// What the client-side tabs do, done here: filtering by bucket has to
// land on the same number the badge shows.
for (const bucket of ['ready', 'needs_draft', 'research'] as const) {
expect(recommendations.filter((r) => r.bucket === bucket)).toHaveLength(counts[bucket]);
}
});

test('the bucket survives a narrowed query', async () => {
const { app, db } = await harness('filter-buckets-narrowed');
await add(db, 'rec_research_1', 'refresh_research', 'website', false);
await add(db, 'rec_email_undrafted', 'send_email', 'email', false);

// The bucket is computed in the SELECT, so narrowing the WHERE clause
// shifts every placeholder after it — the classic way this breaks is a
// filtered query binding the wrong arguments.
const needsDraft = await queue(app, 'needs_draft');
expect(needsDraft.recommendations.map((r) => r.id)).toContain('rec_email_undrafted');
expect(needsDraft.recommendations.every((r) => r.bucket === 'needs_draft')).toBe(true);

const research = await queue(app, 'research');
expect(research.recommendations.map((r) => r.id)).toContain('rec_research_1');
expect(research.recommendations.every((r) => r.bucket === 'research')).toBe(true);
});

test('an unknown filter shows the queue rather than failing', async () => {
const { app, db } = await harness('filter-unknown');
await add(db, 'rec_research_1', 'refresh_research', 'website', false);
Expand Down
21 changes: 18 additions & 3 deletions apps/api/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,30 @@ export async function listPendingRecommendations(
research: `AND r.action NOT IN (${outbound})`,
};

const filterArgs: string[] = filter === 'all' ? [] : [...OUTBOUND_ACTION_KINDS];
// Every row carries the bucket it belongs to, whatever the query was
// narrowed to. That is what lets the page fetch once and switch tabs
// without going back to the server: the classification the tabs sort by
// comes from the same SQL that produces the counts, so a client-side tab
// can never disagree with the badge next to it.
const bucketExpression = `CASE
WHEN r.action NOT IN (${outbound}) THEN 'research'
WHEN d.id IS NOT NULL THEN 'ready'
ELSE 'needs_draft'
END AS bucket`;

// Placeholders bind in the order they appear in the statement, so the
// bucket expression's arguments come first — it sits in the SELECT list,
// ahead of the WHERE clause the filter narrows.
const clauseArgs: string[] = filter === 'all' ? [] : [...OUTBOUND_ACTION_KINDS];

return queryAll(
db,
`SELECT r.*, p.display_name, p.current_title, p.identity_confidence,
s.summary AS signal_summary, s.source_url AS signal_url,
s.source_timestamp AS signal_at,
d.body AS draft_body, d.subject AS draft_subject,
sc.opportunity
sc.opportunity,
${bucketExpression}
FROM recommendations r
JOIN people p ON p.id = r.person_id
LEFT JOIN signals s ON s.id = r.trigger_signal_id
Expand All @@ -162,7 +177,7 @@ export async function listPendingRecommendations(
${clause[filter]}
ORDER BY r.priority DESC, r.created_at ASC
LIMIT ?`,
[workspaceId, now(), ...filterArgs, limit],
[...OUTBOUND_ACTION_KINDS, workspaceId, now(), ...clauseArgs, limit],
);
}

Expand Down
115 changes: 13 additions & 102 deletions apps/web/app/(app)/approvals/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ApprovalCard } from '../../../components/approval-card';
import { ApprovalQueue as Queue } from '../../../components/approval-queue';
import {
ApiUnavailableError,
NotAuthenticatedError,
Expand All @@ -14,32 +13,19 @@ export const dynamic = 'force-dynamic';
export const metadata = { title: 'Approvals · OutreachGraph' };

/**
* The queue, narrowed to what can actually be acted on.
* The whole pending queue, in one request.
*
* It used to be one undifferentiated list, which made it unusable in practice:
* production held 73 `refresh_research` cards — internal actions that have no
* message by definition and never will — against a single email waiting for a
* decision. Each of the 73 renders as a card with nothing written on it, so
* the queue read as "the composer is broken" when it was really "you are
* looking at the wrong 73 rows".
* The tabs used to be four separate URLs, one fetch each, and switching
* between them reloaded the page to show rows the browser already had. The
* page now asks for `all` and the tabs filter it in the client, so the only
* cost of looking at another tab is a re-render.
*
* `ready` is therefore the default rather than `all`: the page opens on the
* things a human can approve, and the rest stay one click away.
* `limit` is the API's own ceiling. Fetching per-tab could show 50 of each;
* fetching once has to cover all four, and production's queue is ~75 rows.
* Past 200 the counts still tell the truth and the page says it is showing a
* subset.
*/
const TABS: readonly { id: ApprovalFilter; label: string; blurb: string }[] = [
{ id: 'ready', label: 'Ready', blurb: 'A message is written and waiting for your decision.' },
{
id: 'needs_draft',
label: 'Needs a draft',
blurb: 'Outreach with no message written yet. Nothing here can be sent.',
},
{
id: 'research',
label: 'Research',
blurb: 'Internal work the prospect never sees. These never have a message.',
},
{ id: 'all', label: 'All', blurb: 'Everything pending, in priority order.' },
];
const QUEUE_LIMIT = 200;

function isFilter(value: string | undefined): value is ApprovalFilter {
return value === 'all' || value === 'ready' || value === 'needs_draft' || value === 'research';
Expand All @@ -56,7 +42,7 @@ export default async function ApprovalsPage({
let queue: ApprovalQueue;

try {
queue = await fetchApprovals(filter);
queue = await fetchApprovals('all', QUEUE_LIMIT);
} catch (error) {
if (error instanceof NotAuthenticatedError) redirect('/login');
// A missing API in local development should show what to do, not a stack
Expand All @@ -65,82 +51,7 @@ export default async function ApprovalsPage({
throw error;
}

const { recommendations: cards, counts } = queue;
const active = TABS.find((tab) => tab.id === filter) ?? TABS[0];

return (
<div className="pt-4">
<header className="mb-3">
<h1 className="text-xl font-semibold">Approvals</h1>
<p className="text-ink-muted text-sm">{active?.blurb}</p>
</header>

<nav className="mb-4 flex flex-wrap gap-2" aria-label="Filter the queue">
{TABS.map((tab) => {
const selected = tab.id === filter;
return (
<Link
key={tab.id}
href={`/approvals?filter=${tab.id}`}
aria-current={selected ? 'page' : undefined}
className={
selected
? 'border-accent text-ink bg-surface-raised rounded-full border px-3 py-1.5 text-sm'
: 'border-border text-ink-muted rounded-full border px-3 py-1.5 text-sm'
}
>
{tab.label}
{/* The count is the point of the tabs: it is what tells you the
queue is 73 research cards rather than 73 broken drafts. */}
<span className="text-ink-muted ml-1.5 text-xs">{counts[tab.id] ?? 0}</span>
</Link>
);
})}
</nav>

{cards.length === 0 ? (
<EmptyState filter={filter} counts={counts} />
) : (
<div className="flex flex-col gap-3">
{cards.map((card) => (
<ApprovalCard key={card.id} card={card} />
))}
</div>
)}
</div>
);
}

function EmptyState({
filter,
counts,
}: {
filter: ApprovalFilter;
counts: Record<ApprovalFilter, number>;
}) {
// "Nothing here" is unhelpful when the queue is full of something else, so
// an empty tab says where the work actually is.
const elsewhere = TABS.filter((tab) => tab.id !== filter && (counts[tab.id] ?? 0) > 0);

return (
<div className="border-border text-ink-muted rounded-2xl border border-dashed p-8 text-center text-sm">
<p>Nothing in this view.</p>
{elsewhere.length > 0 ? (
<p className="mt-2">
{elsewhere.map((tab, index) => (
<span key={tab.id}>
{index > 0 ? ' · ' : ''}
<Link href={`/approvals?filter=${tab.id}`} className="text-accent underline">
{counts[tab.id]} {tab.label.toLowerCase()}
</Link>
</span>
))}
</p>
) : (
<p className="mt-1">New recommendations appear as fresh signals arrive.</p>
)}
</div>
);
return <Queue cards={queue.recommendations} counts={queue.counts} initialFilter={filter} />;
}

function ApiDown() {
Expand Down
Loading
Loading