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:
@@ -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 './insert-grants.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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user