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>
This commit is contained in:
@@ -53,7 +53,15 @@ const SUBSCORE_MAX: Record<string, number> = {
|
|||||||
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||||
const { detail } = loaderData ?? {};
|
const { detail } = loaderData ?? {};
|
||||||
if (detail == null) return null;
|
if (detail == null) return null;
|
||||||
const { match, org, grant, funderGivingHistory } = detail;
|
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 subscores = (match.subscores ?? {}) as Record<string, number>;
|
||||||
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
|
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
|
||||||
const nhHistory = funderGivingHistory;
|
const nhHistory = funderGivingHistory;
|
||||||
@@ -233,6 +241,47 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="rounded border p-4">
|
||||||
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
|
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
|
||||||
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? '—'}</p>
|
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? '—'}</p>
|
||||||
|
|||||||
@@ -151,3 +151,31 @@ describe('parse990PfXml', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('preselected-only indicator', () => {
|
||||||
|
const wrap = (inner: string) => `<?xml version="1.0"?>
|
||||||
|
<Return xmlns="http://www.irs.gov/efile">
|
||||||
|
<ReturnHeader><TaxYr>2024</TaxYr></ReturnHeader>
|
||||||
|
<ReturnData><IRS990PF><SupplementaryInformationGrp>${inner}</SupplementaryInformationGrp></IRS990PF></ReturnData>
|
||||||
|
</Return>`;
|
||||||
|
|
||||||
|
it('captures the checkbox even when the application group is absent', () => {
|
||||||
|
const parsed = parse990PfXml(wrap('<OnlyContriToPreselectedInd>X</OnlyContriToPreselectedInd>'));
|
||||||
|
expect(parsed.applicationInfo).toEqual({ preselectedOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags it alongside application fields when both exist', () => {
|
||||||
|
const parsed = parse990PfXml(wrap(`
|
||||||
|
<OnlyContriToPreselectedInd>X</OnlyContriToPreselectedInd>
|
||||||
|
<ApplicationSubmissionInfoGrp><SubmissionDeadlinesTxt>NONE</SubmissionDeadlinesTxt></ApplicationSubmissionInfoGrp>`));
|
||||||
|
expect(parsed.applicationInfo).toMatchObject({
|
||||||
|
preselectedOnly: true,
|
||||||
|
submissionDeadlines: 'NONE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays absent when unchecked', () => {
|
||||||
|
const parsed = parse990PfXml(wrap('<ApplicationSubmissionInfoGrp><SubmissionDeadlinesTxt>ROLLING</SubmissionDeadlinesTxt></ApplicationSubmissionInfoGrp>'));
|
||||||
|
expect((parsed.applicationInfo as Record<string, unknown>)?.preselectedOnly).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,9 +122,26 @@ function extractApplicationInfo(
|
|||||||
Record<string, unknown>[] | Record<string, unknown> | undefined);
|
Record<string, unknown>[] | Record<string, unknown> | undefined);
|
||||||
const groups = coerceArray<Record<string, unknown>>(raw);
|
const groups = coerceArray<Record<string, unknown>>(raw);
|
||||||
const first = groups[0];
|
const first = groups[0];
|
||||||
if (first == null) return null;
|
|
||||||
|
// Part XV line 2 checkbox: "only makes contributions to preselected
|
||||||
|
// charitable organizations and does not accept unsolicited requests" —
|
||||||
|
// the kill signal for outreach. Checked filings usually OMIT the
|
||||||
|
// application-submission group entirely, so read it from the
|
||||||
|
// supplementary group (or form root) whether or not `first` exists.
|
||||||
|
const preselectedRaw =
|
||||||
|
suppInfo?.OnlyContriToPreselectedInd ?? pf?.OnlyContriToPreselectedInd;
|
||||||
|
const preselectedOnly =
|
||||||
|
preselectedRaw != null &&
|
||||||
|
/^(x|1|true)$/i.test(
|
||||||
|
String(textOf(preselectedRaw) ?? preselectedRaw).trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (first == null) {
|
||||||
|
return preselectedOnly ? { preselectedOnly: true } : null;
|
||||||
|
}
|
||||||
|
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
|
if (preselectedOnly) result.preselectedOnly = true;
|
||||||
const recipientName = textOf(first.RecipientNm);
|
const recipientName = textOf(first.RecipientNm);
|
||||||
if (recipientName != null) result.recipientName = recipientName;
|
if (recipientName != null) result.recipientName = recipientName;
|
||||||
const formAndInfo = textOf(first.FormAndInfoAndMaterialsTxt);
|
const formAndInfo = textOf(first.FormAndInfoAndMaterialsTxt);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type { schema } from '@novelpad/outreach-core';
|
|||||||
import {
|
import {
|
||||||
serverInsertGrants,
|
serverInsertGrants,
|
||||||
serverListFunderLatestObjectIds,
|
serverListFunderLatestObjectIds,
|
||||||
|
serverCloseGrantsForFunders,
|
||||||
serverListFunderSynthesisData,
|
serverListFunderSynthesisData,
|
||||||
serverReplaceFunderGrantsForYear,
|
serverReplaceFunderGrantsForYear,
|
||||||
serverUpsertFunder,
|
serverUpsertFunder,
|
||||||
@@ -462,6 +463,14 @@ export function buildSynthesizedGrant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPreselectedOnly(applicationInfo: unknown): boolean {
|
||||||
|
return (
|
||||||
|
applicationInfo != null &&
|
||||||
|
typeof applicationInfo === 'object' &&
|
||||||
|
(applicationInfo as { preselectedOnly?: unknown }).preselectedOnly === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function synthesizeFunderGrants(db: OutreachDb): Promise<number> {
|
async function synthesizeFunderGrants(db: OutreachDb): Promise<number> {
|
||||||
const rows = await serverListFunderSynthesisData(db, {
|
const rows = await serverListFunderSynthesisData(db, {
|
||||||
recipientState: RECIPIENT_STATE,
|
recipientState: RECIPIENT_STATE,
|
||||||
@@ -469,8 +478,26 @@ async function synthesizeFunderGrants(db: OutreachDb): Promise<number> {
|
|||||||
});
|
});
|
||||||
if (rows.length === 0) return 0;
|
if (rows.length === 0) return 0;
|
||||||
|
|
||||||
|
// Part XV "preselected organizations only" filers don't accept
|
||||||
|
// unsolicited requests — synthesizing a lead from them would produce an
|
||||||
|
// email their own filing says is unwanted. Skip them, and close any
|
||||||
|
// grant rows synthesized before the indicator was parsed.
|
||||||
|
const preselected = rows.filter((row) => isPreselectedOnly(row.applicationInfo));
|
||||||
|
if (preselected.length > 0) {
|
||||||
|
const closed = await serverCloseGrantsForFunders(
|
||||||
|
db,
|
||||||
|
preselected.map((row) => row.ein),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[ingest-990pf] preselected-only funders skipped: ${preselected.length} (${closed} previously-synthesized grant rows closed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pitchable = rows.filter((row) => !isPreselectedOnly(row.applicationInfo));
|
||||||
|
if (pitchable.length === 0) return 0;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const grants = rows.map((row) => buildSynthesizedGrant(row, now));
|
const grants = pitchable.map((row) => buildSynthesizedGrant(row, now));
|
||||||
await serverInsertGrants(db, grants);
|
await serverInsertGrants(db, grants);
|
||||||
return grants.length;
|
return grants.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||||
|
import { schema } from '#~/db/db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes synthesized 990-PF grant rows for the given funder EINs — used
|
||||||
|
* when a re-parse reveals a funder is unpitchable (e.g. Part XV
|
||||||
|
* "preselected organizations only" checkbox). Closed grants drop out of
|
||||||
|
* match retrieval (status filter) on the next run; existing pending
|
||||||
|
* matches referencing them are the caller's cleanup concern.
|
||||||
|
*/
|
||||||
|
export async function serverCloseGrantsForFunders(
|
||||||
|
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||||
|
funderEins: readonly string[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (funderEins.length === 0) return 0;
|
||||||
|
const result = await db
|
||||||
|
.update(schema.grants)
|
||||||
|
.set({ status: 'closed', updatedAt: sql`now()` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.grants.source, 'irs_990pf'),
|
||||||
|
eq(schema.grants.status, 'open'),
|
||||||
|
inArray(schema.grants.funderEin, [...funderEins]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: schema.grants.id });
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './expire-closed-grants.server.js';
|
export * from './expire-closed-grants.server.js';
|
||||||
export * from './insert-grants.server.js';
|
export * from './insert-grants.server.js';
|
||||||
export * from './set-grant-embeddings.server.js';
|
export * from './set-grant-embeddings.server.js';
|
||||||
|
export * from './close-grants-for-funders.server.js';
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface MatchDetail {
|
|||||||
geographicScope: string | null;
|
geographicScope: string | null;
|
||||||
applicationEffortEstimate: string;
|
applicationEffortEstimate: string;
|
||||||
};
|
};
|
||||||
|
/** Part XV application info from the funder's latest parsed 990-PF
|
||||||
|
* (form required, deadlines, restrictions, preselected-only flag). */
|
||||||
|
funderApplicationInfo: unknown;
|
||||||
/** For 990-PF matches: the funder's actual giving history into the
|
/** For 990-PF matches: the funder's actual giving history into the
|
||||||
* org's state — the concrete evidence behind the precedent subscore. */
|
* org's state — the concrete evidence behind the precedent subscore. */
|
||||||
funderGivingHistory: Array<{
|
funderGivingHistory: Array<{
|
||||||
@@ -105,7 +108,14 @@ export async function serverGetMatchDetail(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let funderGivingHistory: MatchDetail['funderGivingHistory'] = [];
|
let funderGivingHistory: MatchDetail['funderGivingHistory'] = [];
|
||||||
|
let funderApplicationInfo: unknown = null;
|
||||||
if (row.grantFunderEin != null) {
|
if (row.grantFunderEin != null) {
|
||||||
|
const funderRow = await db
|
||||||
|
.select({ applicationInfo: schema.funders.applicationInfo })
|
||||||
|
.from(schema.funders)
|
||||||
|
.where(eq(schema.funders.ein, row.grantFunderEin))
|
||||||
|
.limit(1);
|
||||||
|
funderApplicationInfo = funderRow[0]?.applicationInfo ?? null;
|
||||||
const funderRows = await db
|
const funderRows = await db
|
||||||
.select({
|
.select({
|
||||||
recipientName: schema.funderGrants.recipientName,
|
recipientName: schema.funderGrants.recipientName,
|
||||||
@@ -172,6 +182,7 @@ export async function serverGetMatchDetail(
|
|||||||
geographicScope: row.grantGeo,
|
geographicScope: row.grantGeo,
|
||||||
applicationEffortEstimate: row.grantEffort,
|
applicationEffortEstimate: row.grantEffort,
|
||||||
},
|
},
|
||||||
|
funderApplicationInfo,
|
||||||
funderGivingHistory,
|
funderGivingHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user