From a92b75b315e69857e942d0a1951185a4670ca1f1 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 16 Jul 2026 21:46:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(review+990pf):=20Part=20XV=20application?= =?UTF-8?q?=20info=20=E2=80=94=20parse=20the=20preselected-only=20kill=20s?= =?UTF-8?q?ignal,=20surface=20how-to-apply=20at=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/routes/matches.$matchId.tsx | 51 ++++++++++++++++++- .../sources/irs-990pf/parse-990pf-xml.test.ts | 28 ++++++++++ .../src/sources/irs-990pf/parse-990pf-xml.ts | 19 ++++++- .../src/workflows/ingest-990pf.ts | 29 ++++++++++- .../close-grants-for-funders.server.ts | 30 +++++++++++ .../src/grants/actions/index.server.ts | 1 + .../queries/get-match-detail.server.ts | 11 ++++ 7 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 packages/outreach-core/src/grants/actions/close-grants-for-funders.server.ts diff --git a/apps/outreach-review/app/routes/matches.$matchId.tsx b/apps/outreach-review/app/routes/matches.$matchId.tsx index 39eac95..48e8692 100644 --- a/apps/outreach-review/app/routes/matches.$matchId.tsx +++ b/apps/outreach-review/app/routes/matches.$matchId.tsx @@ -53,7 +53,15 @@ const SUBSCORE_MAX: Record = { export default function MatchDetail({ loaderData }: Route.ComponentProps) { const { detail } = loaderData ?? {}; 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; const rationale = (match.rationale ?? {}) as Record; const nhHistory = funderGivingHistory; @@ -233,6 +241,47 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { )} + {applyInfo != null && ( +
+

+ How to apply (funder's own 990-PF, Part XV) +

+ {applyInfo.preselectedOnly === true && ( +

+ This funder states it only contributes to preselected + organizations and does not accept unsolicited requests — do + not pitch. +

+ )} +
+ {applyInfo.formAndInfoAndMaterials != null && ( +
+
Application form:
+
{applyInfo.formAndInfoAndMaterials}
+
+ )} + {applyInfo.submissionDeadlines != null && ( +
+
Deadlines:
+
{applyInfo.submissionDeadlines}
+
+ )} + {applyInfo.restrictionsOnAwards != null && ( +
+
Restrictions:
+
{applyInfo.restrictionsOnAwards}
+
+ )} + {applyInfo.recipientName != null && ( +
+
Applications to:
+
{applyInfo.recipientName}
+
+ )} +
+
+ )} +

Grant synopsis

{grant.synopsis ?? '—'}

diff --git a/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.test.ts b/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.test.ts index e870189..9043e82 100644 --- a/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.test.ts +++ b/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.test.ts @@ -151,3 +151,31 @@ describe('parse990PfXml', () => { }); }); }); + +describe('preselected-only indicator', () => { + const wrap = (inner: string) => ` + + 2024 + ${inner} +`; + + it('captures the checkbox even when the application group is absent', () => { + const parsed = parse990PfXml(wrap('X')); + expect(parsed.applicationInfo).toEqual({ preselectedOnly: true }); + }); + + it('flags it alongside application fields when both exist', () => { + const parsed = parse990PfXml(wrap(` + X + NONE`)); + expect(parsed.applicationInfo).toMatchObject({ + preselectedOnly: true, + submissionDeadlines: 'NONE', + }); + }); + + it('stays absent when unchecked', () => { + const parsed = parse990PfXml(wrap('ROLLING')); + expect((parsed.applicationInfo as Record)?.preselectedOnly).toBeUndefined(); + }); +}); diff --git a/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.ts b/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.ts index d243cb3..ff3bcb6 100644 --- a/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.ts +++ b/apps/outreach-worker/src/sources/irs-990pf/parse-990pf-xml.ts @@ -122,9 +122,26 @@ function extractApplicationInfo( Record[] | Record | undefined); const groups = coerceArray>(raw); 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 = {}; + if (preselectedOnly) result.preselectedOnly = true; const recipientName = textOf(first.RecipientNm); if (recipientName != null) result.recipientName = recipientName; const formAndInfo = textOf(first.FormAndInfoAndMaterialsTxt); diff --git a/apps/outreach-worker/src/workflows/ingest-990pf.ts b/apps/outreach-worker/src/workflows/ingest-990pf.ts index 5709861..cfc4e44 100644 --- a/apps/outreach-worker/src/workflows/ingest-990pf.ts +++ b/apps/outreach-worker/src/workflows/ingest-990pf.ts @@ -49,6 +49,7 @@ import type { schema } from '@novelpad/outreach-core'; import { serverInsertGrants, serverListFunderLatestObjectIds, + serverCloseGrantsForFunders, serverListFunderSynthesisData, serverReplaceFunderGrantsForYear, 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 { const rows = await serverListFunderSynthesisData(db, { recipientState: RECIPIENT_STATE, @@ -469,8 +478,26 @@ async function synthesizeFunderGrants(db: OutreachDb): Promise { }); 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 grants = rows.map((row) => buildSynthesizedGrant(row, now)); + const grants = pitchable.map((row) => buildSynthesizedGrant(row, now)); await serverInsertGrants(db, grants); return grants.length; } diff --git a/packages/outreach-core/src/grants/actions/close-grants-for-funders.server.ts b/packages/outreach-core/src/grants/actions/close-grants-for-funders.server.ts new file mode 100644 index 0000000..52e201d --- /dev/null +++ b/packages/outreach-core/src/grants/actions/close-grants-for-funders.server.ts @@ -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 { + 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; +} diff --git a/packages/outreach-core/src/grants/actions/index.server.ts b/packages/outreach-core/src/grants/actions/index.server.ts index a57be42..279e2b7 100644 --- a/packages/outreach-core/src/grants/actions/index.server.ts +++ b/packages/outreach-core/src/grants/actions/index.server.ts @@ -1,3 +1,4 @@ export * from './expire-closed-grants.server.js'; export * from './insert-grants.server.js'; export * from './set-grant-embeddings.server.js'; +export * from './close-grants-for-funders.server.js'; 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 index 1a30a9c..57d39db 100644 --- a/packages/outreach-core/src/matches/queries/get-match-detail.server.ts +++ b/packages/outreach-core/src/matches/queries/get-match-detail.server.ts @@ -40,6 +40,9 @@ export interface MatchDetail { geographicScope: string | null; 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 * org's state — the concrete evidence behind the precedent subscore. */ funderGivingHistory: Array<{ @@ -105,7 +108,14 @@ export async function serverGetMatchDetail( .limit(1); let funderGivingHistory: MatchDetail['funderGivingHistory'] = []; + let funderApplicationInfo: unknown = 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 .select({ recipientName: schema.funderGrants.recipientName, @@ -172,6 +182,7 @@ export async function serverGetMatchDetail( geographicScope: row.grantGeo, applicationEffortEstimate: row.grantEffort, }, + funderApplicationInfo, funderGivingHistory, }; }