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 (
+
+
+
+
+
+
+
+
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.
+
+
+
+
+ | Recipient |
+ City |
+ Amount |
+ Year |
+ Purpose |
+
+
+
+ {nhHistory.map((g, i) => (
+
+ | {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';