From d8ca133e95afb06d338bc52555afdd41d5abd69a Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 16 Jul 2026 21:32:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(review):=20match=20detail=20view=20?= =?UTF-8?q?=E2=80=94=20evidence=20for=20fit=20at=20review=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /matches/:matchId shows the org (location, NTEE + description, revenue, profile mission w/ stub warning, ProPublica + web-search links), the grant (award band, deadline, eligibility, source link), the full subscore breakdown with embedding similarity + ignored-gate flags, the grant synopsis, and — for 990-PF matches — the funder's actual giving history table (recipient, city, amount, year, purpose; in-state first), which is the concrete evidence behind the precedent score. Approve/ reject on the detail page redirects back to the queue; queue org names link through. Core: serverGetMatchDetail (match+org+grant join, profile, up to 40 funder-grant rows in-state-first). Co-Authored-By: Claude Fable 5 --- apps/outreach-review/app/routes.ts | 7 +- apps/outreach-review/app/routes/_index.tsx | 11 +- .../app/routes/matches.$matchId.tsx | 278 ++++++++++++++++++ .../queries/get-match-detail.server.ts | 177 +++++++++++ .../src/matches/queries/index.server.ts | 1 + 5 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 apps/outreach-review/app/routes/matches.$matchId.tsx create mode 100644 packages/outreach-core/src/matches/queries/get-match-detail.server.ts diff --git a/apps/outreach-review/app/routes.ts b/apps/outreach-review/app/routes.ts index 17cbb9d..f817e04 100644 --- a/apps/outreach-review/app/routes.ts +++ b/apps/outreach-review/app/routes.ts @@ -1,3 +1,6 @@ -import { index, type RouteConfig } from '@react-router/dev/routes'; +import { index, route, type RouteConfig } from '@react-router/dev/routes'; -export default [index('routes/_index.tsx')] satisfies RouteConfig; +export default [ + index('routes/_index.tsx'), + route('matches/:matchId', 'routes/matches.$matchId.tsx'), +] satisfies RouteConfig; diff --git a/apps/outreach-review/app/routes/_index.tsx b/apps/outreach-review/app/routes/_index.tsx index 2bf79d0..051628f 100644 --- a/apps/outreach-review/app/routes/_index.tsx +++ b/apps/outreach-review/app/routes/_index.tsx @@ -2,7 +2,7 @@ import { serverListPendingReviewMatches, serverSetMatchReview, } from '@novelpad/outreach-core/server'; -import { Form } from 'react-router'; +import { Form, Link } from 'react-router'; import { db } from '#~/db.server.js'; @@ -69,7 +69,14 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) { {matches.map((match) => ( - {match.orgName} + + + {match.orgName} + + {match.grantTitle} {match.totalScore} {match.easyWin ? 'Yes' : 'No'} diff --git a/apps/outreach-review/app/routes/matches.$matchId.tsx b/apps/outreach-review/app/routes/matches.$matchId.tsx new file mode 100644 index 0000000..39eac95 --- /dev/null +++ b/apps/outreach-review/app/routes/matches.$matchId.tsx @@ -0,0 +1,278 @@ +import { nteeDescription } from '@novelpad/outreach-core'; +import { + serverGetMatchDetail, + serverSetMatchReview, +} from '@novelpad/outreach-core/server'; +import { Form, Link, redirect } from 'react-router'; + +import { db } from '#~/db.server.js'; + +import type { Route } from './+types/matches.$matchId.js'; + +export async function loader({ request, params }: Route.LoaderArgs) { + if (request.method === 'HEAD') return; + + const detail = await serverGetMatchDetail(db, params.matchId); + if (detail == null) { + throw new Response('Match not found', { status: 404 }); + } + return { detail }; +} + +export async function action({ request, params }: Route.ActionArgs) { + if (request.method === 'HEAD') return; + + const formData = await request.formData(); + const decision = formData.get('decision'); + if (decision !== 'approved' && decision !== 'rejected') { + throw new Response('Invalid review submission', { status: 400 }); + } + + await serverSetMatchReview(db, { + matchId: params.matchId, + reviewStatus: decision, + rejectReason: decision === 'rejected' ? 'other' : undefined, + }); + return redirect('/'); +} + +function dollars(n: number | null): string { + if (n == null) return '—'; + return `$${n.toLocaleString('en-US')}`; +} + +const SUBSCORE_MAX: Record = { + missionFit: 30, + funderPrecedent: 25, + capacityFit: 15, + competition: 15, + effort: 10, + runway: 5, +}; + +export default function MatchDetail({ loaderData }: Route.ComponentProps) { + const { detail } = loaderData ?? {}; + if (detail == null) return null; + const { match, org, grant, funderGivingHistory } = detail; + const subscores = (match.subscores ?? {}) as Record; + const rationale = (match.rationale ?? {}) as Record; + const nhHistory = funderGivingHistory; + + return ( +
+ + +
+
+

+ {org.name} ↔ {grant.funder} +

+

+ Score {match.totalScore}/100 + {match.easyWin ? ' · Easy win' : ''} + {match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus} +

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+

Organization

+
+
+
Location:
+
+ {[org.city, org.state].filter(Boolean).join(', ')} +
+
+
+
Focus (NTEE):
+
+ {org.nteeCode ?? '—'} + {nteeDescription(org.nteeCode) != null && + ` — ${nteeDescription(org.nteeCode)}`} +
+
+
+
Annual revenue:
+
{dollars(org.totalRevenue)}
+
+
+
Mission (profile):
+
+ {org.missionStatement ?? '—'} + {org.profileConfidence != null && + org.profileConfidence <= 0.2 && ( + + {' '} + (auto-generated stub — verify on their website) + + )} +
+
+
+ +
+ +
+

Grant

+
+
+
Title:
+
{grant.title}
+
+
+
Award:
+
+ {dollars(grant.awardFloor)} – {dollars(grant.awardCeiling)} +
+
+
+
Closes:
+
+ {grant.closeDate == null + ? 'Rolling / no stated deadline' + : new Date(grant.closeDate).toLocaleDateString('en-US')} +
+
+
+
Source:
+
{grant.source}
+
+ {grant.eligibilityEntityTypes != null && + grant.eligibilityEntityTypes.length > 0 && ( +
+
Eligibility:
+
+ {grant.eligibilityEntityTypes.join('; ')} +
+
+ )} +
+ +
+
+ +
+

Score breakdown

+ + + {Object.entries(SUBSCORE_MAX).map(([key, max]) => ( + + + + + ))} + +
+ {key.replace(/([A-Z])/g, ' $1').toLowerCase()} + + {subscores[key] ?? 0} / {max} +
+ {typeof rationale.similarity === 'number' && ( +

+ Embedding similarity: {(rationale.similarity as number).toFixed(3)} + {Array.isArray(rationale.gateFailures) && + (rationale.gateFailures as string[]).length > 0 && + ` · Ignored gate flags: ${(rationale.gateFailures as string[]).join(', ')}`} +

+ )} +
+ +
+

Grant synopsis

+

{grant.synopsis ?? '—'}

+
+ + {nhHistory.length > 0 && ( +
+

+ Funder giving history (from 990-PF filings) +

+

+ The concrete evidence behind the precedent score — who this + funder actually paid, sorted in-state first. +

+ + + + + + + + + + + + {nhHistory.map((g, i) => ( + + + + + + + + ))} + +
RecipientCityAmountYearPurpose
{g.recipientName}{g.recipientCity ?? '—'} + {dollars(g.amount)} + {g.taxYear}{g.purpose ?? '—'}
+
+ )} +
+ ); +} diff --git a/packages/outreach-core/src/matches/queries/get-match-detail.server.ts b/packages/outreach-core/src/matches/queries/get-match-detail.server.ts new file mode 100644 index 0000000..1a30a9c --- /dev/null +++ b/packages/outreach-core/src/matches/queries/get-match-detail.server.ts @@ -0,0 +1,177 @@ +import { desc, eq } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +export interface MatchDetail { + match: { + id: string; + totalScore: number; + subscores: unknown; + rationale: unknown; + easyWin: boolean; + isHero: boolean; + reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited'; + }; + org: { + id: string; + name: string; + city: string | null; + state: string; + ein: string | null; + nteeCode: string | null; + totalRevenue: number | null; + registrationNumber: string | null; + missionStatement: string | null; + profileConfidence: number | null; + }; + grant: { + id: string; + title: string; + funder: string; + funderEin: string | null; + synopsis: string | null; + awardFloor: number | null; + awardCeiling: number | null; + closeDate: Date | null; + sourceUrl: string; + source: string; + eligibilityEntityTypes: string[] | null; + geographicScope: string | null; + applicationEffortEstimate: string; + }; + /** For 990-PF matches: the funder's actual giving history into the + * org's state — the concrete evidence behind the precedent subscore. */ + funderGivingHistory: Array<{ + recipientName: string; + recipientCity: string | null; + amount: number | null; + purpose: string | null; + taxYear: number; + }>; +} + +export async function serverGetMatchDetail( + db: NpOutreachDatabase | NpOutreachTransaction, + matchId: string, +): Promise { + const rows = await db + .select({ + matchId: schema.matches.id, + totalScore: schema.matches.totalScore, + subscores: schema.matches.subscores, + rationale: schema.matches.rationale, + easyWin: schema.matches.easyWin, + isHero: schema.matches.isHero, + reviewStatus: schema.matches.reviewStatus, + orgId: schema.orgs.id, + orgName: schema.orgs.name, + orgCity: schema.orgs.city, + orgState: schema.orgs.state, + orgEin: schema.orgs.ein, + orgNtee: schema.orgs.nteeCode, + orgRevenue: schema.orgs.totalRevenue, + orgRegNo: schema.orgs.registrationNumber, + grantId: schema.grants.id, + grantTitle: schema.grants.title, + grantFunder: schema.grants.funder, + grantFunderEin: schema.grants.funderEin, + grantSynopsis: schema.grants.synopsis, + grantFloor: schema.grants.awardFloor, + grantCeiling: schema.grants.awardCeiling, + grantClose: schema.grants.closeDate, + grantSourceUrl: schema.grants.sourceUrl, + grantSource: schema.grants.source, + grantEligibility: schema.grants.eligibilityEntityTypes, + grantGeo: schema.grants.geographicScope, + grantEffort: schema.grants.applicationEffortEstimate, + }) + .from(schema.matches) + .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) + .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) + .where(eq(schema.matches.id, matchId)) + .limit(1); + + const row = rows[0]; + if (row == null) return null; + + const profiles = await db + .select({ + missionStatement: schema.orgProfiles.missionStatement, + confidence: schema.orgProfiles.confidence, + }) + .from(schema.orgProfiles) + .where(eq(schema.orgProfiles.orgId, row.orgId)) + .limit(1); + + let funderGivingHistory: MatchDetail['funderGivingHistory'] = []; + if (row.grantFunderEin != null) { + const funderRows = await db + .select({ + recipientName: schema.funderGrants.recipientName, + recipientCity: schema.funderGrants.recipientCity, + recipientState: schema.funderGrants.recipientState, + amount: schema.funderGrants.amount, + purpose: schema.funderGrants.purpose, + taxYear: schema.funderGrants.taxYear, + }) + .from(schema.funderGrants) + .innerJoin( + schema.funders, + eq(schema.funderGrants.funderId, schema.funders.id), + ) + .where(eq(schema.funders.ein, row.grantFunderEin)) + .orderBy(desc(schema.funderGrants.taxYear), desc(schema.funderGrants.amount)) + .limit(200); + + // In-state grants first (the precedent evidence), then the rest. + funderGivingHistory = [...funderRows] + .sort((a, b) => { + const aIn = a.recipientState === row.orgState ? 0 : 1; + const bIn = b.recipientState === row.orgState ? 0 : 1; + return aIn - bIn; + }) + .slice(0, 40) + .map(({ recipientState: _s, ...rest }) => rest); + } + + return { + match: { + id: row.matchId, + totalScore: row.totalScore, + subscores: row.subscores, + rationale: row.rationale, + easyWin: row.easyWin, + isHero: row.isHero, + reviewStatus: row.reviewStatus, + }, + org: { + id: row.orgId, + name: row.orgName, + city: row.orgCity, + state: row.orgState, + ein: row.orgEin, + nteeCode: row.orgNtee, + totalRevenue: row.orgRevenue, + registrationNumber: row.orgRegNo, + missionStatement: profiles[0]?.missionStatement ?? null, + profileConfidence: profiles[0]?.confidence ?? null, + }, + grant: { + id: row.grantId, + title: row.grantTitle, + funder: row.grantFunder, + funderEin: row.grantFunderEin, + synopsis: row.grantSynopsis, + awardFloor: row.grantFloor, + awardCeiling: row.grantCeiling, + closeDate: row.grantClose, + sourceUrl: row.grantSourceUrl, + source: row.grantSource, + eligibilityEntityTypes: row.grantEligibility, + geographicScope: row.grantGeo, + applicationEffortEstimate: row.grantEffort, + }, + funderGivingHistory, + }; +} diff --git a/packages/outreach-core/src/matches/queries/index.server.ts b/packages/outreach-core/src/matches/queries/index.server.ts index 691ea67..c1dc6f4 100644 --- a/packages/outreach-core/src/matches/queries/index.server.ts +++ b/packages/outreach-core/src/matches/queries/index.server.ts @@ -1 +1,2 @@ export * from './list-pending-review-matches.server.js'; +export * from './get-match-detail.server.js';