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:
Croissant Le Doux
2026-07-16 21:46:58 -04:00
parent d8ca133e95
commit a92b75b315
7 changed files with 166 additions and 3 deletions

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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,
};
}