Files
grant-outreach-engine/apps/outreach-review/app/routes/matches.$matchId.tsx
Croissant Le Doux a92b75b315 feat(review+990pf): Part XV application info — parse the preselected-only kill signal, surface how-to-apply at review
Parser captures 990-PF Part XV line 2 (OnlyContriToPreselectedInd) even
when the application group is absent. Synthesis skips preselected-only
funders and closes their previously-synthesized grant rows
(serverCloseGrantsForFunders). Match detail page gains a 'How to apply
(funder's own 990-PF, Part XV)' panel — application form, deadlines,
restrictions, red do-not-pitch banner — fed by serverGetMatchDetail.

Backfill re-parse across all 747 NH foundations: 192 are preselected-
only; 62 of 123 synthesized foundation leads were unpitchable by their
funder's own filing and are now closed, with 24,436 stale pending
matches cleaned. Post re-match: 714 easy wins across 327 orgs, every
foundation lead now backed by a funder that actually accepts
applications. 156 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:46:58 -04:00

328 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number> = {
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, funderApplicationInfo } =
detail;
const applyInfo = (funderApplicationInfo ?? null) as {
preselectedOnly?: boolean;
recipientName?: string;
formAndInfoAndMaterials?: string;
submissionDeadlines?: string;
restrictionsOnAwards?: string;
} | null;
const subscores = (match.subscores ?? {}) as Record<string, number>;
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
const nhHistory = funderGivingHistory;
return (
<main className="container mx-auto max-w-4xl space-y-6 p-6">
<nav>
<Link to="/" className="text-blue-700 underline">
Back to queue
</Link>
</nav>
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">
{org.name} {grant.funder}
</h1>
<p className="text-gray-600">
Score {match.totalScore}/100
{match.easyWin ? ' · Easy win' : ''}
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Form method="post">
<input type="hidden" name="decision" value="approved" />
<button
type="submit"
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
Approve
</button>
</Form>
<Form method="post">
<input type="hidden" name="decision" value="rejected" />
<button
type="submit"
className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
>
Reject
</button>
</Form>
</div>
</header>
<section className="grid gap-6 md:grid-cols-2">
<div className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Organization</h2>
<dl className="space-y-1 text-sm">
<div>
<dt className="inline font-medium">Location: </dt>
<dd className="inline">
{[org.city, org.state].filter(Boolean).join(', ')}
</dd>
</div>
<div>
<dt className="inline font-medium">Focus (NTEE): </dt>
<dd className="inline">
{org.nteeCode ?? '—'}
{nteeDescription(org.nteeCode) != null &&
`${nteeDescription(org.nteeCode)}`}
</dd>
</div>
<div>
<dt className="inline font-medium">Annual revenue: </dt>
<dd className="inline">{dollars(org.totalRevenue)}</dd>
</div>
<div>
<dt className="inline font-medium">Mission (profile): </dt>
<dd className="inline">
{org.missionStatement ?? '—'}
{org.profileConfidence != null &&
org.profileConfidence <= 0.2 && (
<span className="text-amber-700">
{' '}
(auto-generated stub verify on their website)
</span>
)}
</dd>
</div>
</dl>
<div className="mt-3 space-x-3 text-sm">
{org.ein != null && (
<a
className="text-blue-700 underline"
href={`https://projects.propublica.org/nonprofits/organizations/${org.ein.replace(/^0+/, '')}`}
target="_blank"
rel="noreferrer"
>
ProPublica filings
</a>
)}
<a
className="text-blue-700 underline"
href={`https://www.google.com/search?q=${encodeURIComponent(`"${org.name}" ${org.city ?? ''} NH nonprofit`)}`}
target="_blank"
rel="noreferrer"
>
Search their site
</a>
</div>
</div>
<div className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Grant</h2>
<dl className="space-y-1 text-sm">
<div>
<dt className="inline font-medium">Title: </dt>
<dd className="inline">{grant.title}</dd>
</div>
<div>
<dt className="inline font-medium">Award: </dt>
<dd className="inline">
{dollars(grant.awardFloor)} {dollars(grant.awardCeiling)}
</dd>
</div>
<div>
<dt className="inline font-medium">Closes: </dt>
<dd className="inline">
{grant.closeDate == null
? 'Rolling / no stated deadline'
: new Date(grant.closeDate).toLocaleDateString('en-US')}
</dd>
</div>
<div>
<dt className="inline font-medium">Source: </dt>
<dd className="inline">{grant.source}</dd>
</div>
{grant.eligibilityEntityTypes != null &&
grant.eligibilityEntityTypes.length > 0 && (
<div>
<dt className="inline font-medium">Eligibility: </dt>
<dd className="inline">
{grant.eligibilityEntityTypes.join('; ')}
</dd>
</div>
)}
</dl>
<div className="mt-3 text-sm">
<a
className="text-blue-700 underline"
href={grant.sourceUrl}
target="_blank"
rel="noreferrer"
>
{grant.source === 'irs_990pf'
? 'Funder on ProPublica ↗'
: 'Grant listing ↗'}
</a>
</div>
</div>
</section>
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Score breakdown</h2>
<table className="w-full max-w-md text-sm">
<tbody>
{Object.entries(SUBSCORE_MAX).map(([key, max]) => (
<tr key={key} className="border-b">
<td className="py-1 capitalize">
{key.replace(/([A-Z])/g, ' $1').toLowerCase()}
</td>
<td className="py-1 text-right tabular-nums">
{subscores[key] ?? 0} / {max}
</td>
</tr>
))}
</tbody>
</table>
{typeof rationale.similarity === 'number' && (
<p className="mt-2 text-sm text-gray-600">
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(', ')}`}
</p>
)}
</section>
{applyInfo != null && (
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">
How to apply (funder's own 990-PF, Part XV)
</h2>
{applyInfo.preselectedOnly === true && (
<p className="mb-2 rounded bg-red-100 p-2 text-sm font-medium text-red-800">
This funder states it only contributes to preselected
organizations and does not accept unsolicited requests — do
not pitch.
</p>
)}
<dl className="space-y-1 text-sm">
{applyInfo.formAndInfoAndMaterials != null && (
<div>
<dt className="inline font-medium">Application form: </dt>
<dd className="inline">{applyInfo.formAndInfoAndMaterials}</dd>
</div>
)}
{applyInfo.submissionDeadlines != null && (
<div>
<dt className="inline font-medium">Deadlines: </dt>
<dd className="inline">{applyInfo.submissionDeadlines}</dd>
</div>
)}
{applyInfo.restrictionsOnAwards != null && (
<div>
<dt className="inline font-medium">Restrictions: </dt>
<dd className="inline">{applyInfo.restrictionsOnAwards}</dd>
</div>
)}
{applyInfo.recipientName != null && (
<div>
<dt className="inline font-medium">Applications to: </dt>
<dd className="inline">{applyInfo.recipientName}</dd>
</div>
)}
</dl>
</section>
)}
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? ''}</p>
</section>
{nhHistory.length > 0 && (
<section className="rounded border p-4">
<h2 className="mb-1 text-lg font-semibold">
Funder giving history (from 990-PF filings)
</h2>
<p className="mb-3 text-sm text-gray-600">
The concrete evidence behind the precedent score — who this
funder actually paid, sorted in-state first.
</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-1 pr-2">Recipient</th>
<th className="py-1 pr-2">City</th>
<th className="py-1 pr-2 text-right">Amount</th>
<th className="py-1 pr-2">Year</th>
<th className="py-1">Purpose</th>
</tr>
</thead>
<tbody>
{nhHistory.map((g, i) => (
<tr key={i} className="border-b align-top">
<td className="py-1 pr-2">{g.recipientName}</td>
<td className="py-1 pr-2">{g.recipientCity ?? ''}</td>
<td className="py-1 pr-2 text-right tabular-nums">
{dollars(g.amount)}
</td>
<td className="py-1 pr-2">{g.taxYear}</td>
<td className="py-1">{g.purpose ?? ''}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</main>
);
}