From 88affbeb4fb2c581708e5b3c6b90869e9e736f1a Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 16 Jul 2026 23:18:02 -0400 Subject: [PATCH] feat(scoring): federal funder precedent via USASpending (ALN-level) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precedent was always a valid criterion for federal grants — the index just didn't cover them. Now: ingest captures ALN/CFDA numbers from fetchOpportunity (grants.alns); nightly federalPrecedent workflow (04:45) queries USASpending award search per distinct program (free official API, 3-year NH lookback) and stamps program_state_award_count + sample recipients onto open grants (multi-ALN keeps highest). Match retrieval feeds the same funderStateGrantCount input and 25-point tiers foundations use; detail page shows the recipients-evidence table with an incumbent-renewal caution. Also: detail refresh now rotates oldest-verified-first (serverMapGrantVerification) — the Set-based partition re-fetched the same 200 every pass, leaving 365/565 grants ALN-less. Live: 564/564 grants ALN-tagged, 156 programs swept, 85 with NH history, 468 grants carrying precedent, first federal easy-wins (66pts, 25/25 precedent, Aug-24 deadline). 158 tests green. Co-Authored-By: Claude Fable 5 --- .../app/routes/matches.$matchId.tsx | 10 +- apps/outreach-worker/src/main.ts | 2 + apps/outreach-worker/src/run-once.ts | 7 + .../src/sources/grants-gov/client.ts | 5 + .../src/sources/grants-gov/normalize.test.ts | 21 + .../src/sources/grants-gov/normalize.ts | 19 + .../src/sources/usaspending/client.ts | 93 ++ .../src/workflows/federal-precedent.ts | 194 +++ .../src/workflows/ingest-grants.ts | 20 +- docs/features/scoring.md | 8 + .../server/1784257140_federal-precedent.sql | 3 + .../server/meta/1784257140_snapshot.json | 1478 +++++++++++++++++ .../drizzle/server/meta/_journal.json | 7 + packages/outreach-core/src/db/schema.ts | 10 + .../src/grants/actions/index.server.ts | 1 + .../grants/actions/insert-grants.server.ts | 2 + .../actions/set-federal-precedent.server.ts | 50 + .../src/grants/queries/index.server.ts | 1 + .../list-eligible-grants-for-org.server.ts | 15 +- .../queries/list-federal-alns.server.ts | 25 + .../queries/list-grant-source-urls.server.ts | 29 +- .../queries/get-match-detail.server.ts | 18 + 22 files changed, 1995 insertions(+), 23 deletions(-) create mode 100644 apps/outreach-worker/src/sources/usaspending/client.ts create mode 100644 apps/outreach-worker/src/workflows/federal-precedent.ts create mode 100644 packages/outreach-core/drizzle/server/1784257140_federal-precedent.sql create mode 100644 packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json create mode 100644 packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts create mode 100644 packages/outreach-core/src/grants/queries/list-federal-alns.server.ts diff --git a/apps/outreach-review/app/routes/matches.$matchId.tsx b/apps/outreach-review/app/routes/matches.$matchId.tsx index 48e8692..043effc 100644 --- a/apps/outreach-review/app/routes/matches.$matchId.tsx +++ b/apps/outreach-review/app/routes/matches.$matchId.tsx @@ -290,11 +290,15 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { {nhHistory.length > 0 && (

- Funder giving history (from 990-PF filings) + {grant.source === 'irs_990pf' + ? 'Funder giving history (from 990-PF filings)' + : 'Recent program awards to NH recipients (USASpending)'}

- The concrete evidence behind the precedent score — who this - funder actually paid, sorted in-state first. + The concrete evidence behind the precedent score —{' '} + {grant.source === 'irs_990pf' + ? 'who this funder actually paid, sorted in-state first.' + : 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'}

diff --git a/apps/outreach-worker/src/main.ts b/apps/outreach-worker/src/main.ts index 11ca38f..c5a9cfe 100644 --- a/apps/outreach-worker/src/main.ts +++ b/apps/outreach-worker/src/main.ts @@ -33,6 +33,7 @@ import pg from 'pg'; import { setEmbedGrantsDeps } from './workflows/embed-grants.js'; import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js'; import { setExpireGrantsDeps } from './workflows/expire-grants.js'; +import { setFederalPrecedentDeps } from './workflows/federal-precedent.js'; import { setIngestGrantsDeps } from './workflows/ingest-grants.js'; import { setIngest990pfDeps } from './workflows/ingest-990pf.js'; import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js'; @@ -71,6 +72,7 @@ async function main() { setEmbedGrantsDeps({ db }); setMatchGrantsDeps({ db }); setIngest990pfDeps({ db }); + setFederalPrecedentDeps({ db }); DBOS.setConfig({ name: 'helmdocs-outreach-worker', diff --git a/apps/outreach-worker/src/run-once.ts b/apps/outreach-worker/src/run-once.ts index e346afc..27ded5d 100644 --- a/apps/outreach-worker/src/run-once.ts +++ b/apps/outreach-worker/src/run-once.ts @@ -28,6 +28,10 @@ import { runExpireGrantsNow, setExpireGrantsDeps, } from './workflows/expire-grants.js'; +import { + runFederalPrecedentNow, + setFederalPrecedentDeps, +} from './workflows/federal-precedent.js'; import { runIngestGrantsNow, setIngestGrantsDeps, @@ -58,6 +62,7 @@ const RUNNERS: Record Promise> = { embedGrants: runEmbedGrantsNow, matchGrants: runMatchGrantsNow, ingest990pf: runIngest990PfNow, + federalPrecedent: runFederalPrecedentNow, }; const FIRST_RUN_ORDER = [ @@ -67,6 +72,7 @@ const FIRST_RUN_ORDER = [ 'expireGrants', 'enrichOrgs', 'ingest990pf', + 'federalPrecedent', 'embedGrants', 'matchGrants', ]; @@ -106,6 +112,7 @@ async function main() { setEmbedGrantsDeps({ db }); setMatchGrantsDeps({ db }); setIngest990pfDeps({ db }); + setFederalPrecedentDeps({ db }); DBOS.setConfig({ name: 'helmdocs-outreach-worker', diff --git a/apps/outreach-worker/src/sources/grants-gov/client.ts b/apps/outreach-worker/src/sources/grants-gov/client.ts index c5ca279..5a1d226 100644 --- a/apps/outreach-worker/src/sources/grants-gov/client.ts +++ b/apps/outreach-worker/src/sources/grants-gov/client.ts @@ -82,6 +82,11 @@ export interface GrantsGovOpportunityDetail { readonly agencyCode?: string | null; } | null; readonly synopsis: GrantsGovSynopsis | null; + /** Assistance Listing (CFDA) entries, e.g. [{ cfdaNumber: '93.243' }]. */ + readonly cfdas?: ReadonlyArray<{ + readonly cfdaNumber?: string | null; + readonly programTitle?: string | null; + }> | null; } interface GrantsGovDetailResponseEnvelope { diff --git a/apps/outreach-worker/src/sources/grants-gov/normalize.test.ts b/apps/outreach-worker/src/sources/grants-gov/normalize.test.ts index 9d3a1d9..76538e3 100644 --- a/apps/outreach-worker/src/sources/grants-gov/normalize.test.ts +++ b/apps/outreach-worker/src/sources/grants-gov/normalize.test.ts @@ -9,6 +9,7 @@ import { searchHitFixture, } from '#~/sources/grants-gov/fixtures.js'; import { + normalizeAlns, normalizeApplicantTypes, normalizeGrantsGovOpportunity, parseCount, @@ -232,3 +233,23 @@ describe('normalizeApplicantTypes', () => { expect(normalizeApplicantTypes([])).toBeNull(); }); }); + +describe('normalizeAlns', () => { + it('extracts distinct valid ALN numbers', () => { + expect( + normalizeAlns([ + { cfdaNumber: '93.243' }, + { cfdaNumber: ' 93.243 ' }, + { cfdaNumber: '16.888' }, + { cfdaNumber: 'garbage' }, + { cfdaNumber: null }, + ]), + ).toEqual(['93.243', '16.888']); + }); + + it('returns null for empty or missing lists', () => { + expect(normalizeAlns(null)).toBeNull(); + expect(normalizeAlns([])).toBeNull(); + expect(normalizeAlns([{ cfdaNumber: 'x' }])).toBeNull(); + }); +}); diff --git a/apps/outreach-worker/src/sources/grants-gov/normalize.ts b/apps/outreach-worker/src/sources/grants-gov/normalize.ts index 1a2a213..5645af5 100644 --- a/apps/outreach-worker/src/sources/grants-gov/normalize.ts +++ b/apps/outreach-worker/src/sources/grants-gov/normalize.ts @@ -98,6 +98,24 @@ export function stripHtml(html: string | null | undefined): string | null { } /** Extracts non-empty eligibility descriptions from `synopsis.applicantTypes`. */ +/** Distinct, trimmed ALN/CFDA numbers ('93.243') from the detail's cfdas list. */ +export function normalizeAlns( + cfdas: + | ReadonlyArray<{ readonly cfdaNumber?: string | null }> + | null + | undefined, +): string[] | null { + if (cfdas == null) return null; + const nums = [ + ...new Set( + cfdas + .map((c) => c.cfdaNumber?.trim()) + .filter((v): v is string => v != null && /^\d{2}\.\d{3}$/.test(v)), + ), + ]; + return nums.length > 0 ? nums : null; +} + export function normalizeApplicantTypes( applicantTypes: ReadonlyArray | null | undefined, ): string[] | null { @@ -162,6 +180,7 @@ export function normalizeGrantsGovOpportunity( title: detail.opportunityTitle ?? hit.title, funder: resolveFunder(hit, detail), synopsis: stripHtml(synopsis?.synopsisDesc), + alns: normalizeAlns(detail.cfdas), eligibilityEntityTypes: normalizeApplicantTypes( synopsis?.applicantTypes, ), diff --git a/apps/outreach-worker/src/sources/usaspending/client.ts b/apps/outreach-worker/src/sources/usaspending/client.ts new file mode 100644 index 0000000..6a585ff --- /dev/null +++ b/apps/outreach-worker/src/sources/usaspending/client.ts @@ -0,0 +1,93 @@ +/** + * USASpending.gov award-search client — the federal analog of the 990-PF + * grants-paid index. Official API, free, no key. + * Docs: https://api.usaspending.gov/docs/endpoints + */ + +const SEARCH_URL = + 'https://api.usaspending.gov/api/v2/search/spending_by_award/'; + +/** Grant-shaped assistance award type codes (block/formula/project/coop). */ +const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05']; + +const DEFAULT_POLITENESS_DELAY_MS = 300; + +export interface ProgramStateAwardSample { + readonly recipientName: string; + readonly amount: number | null; + readonly startDate: string | null; +} + +export interface ProgramStateAwards { + readonly aln: string; + readonly awardCount: number; + readonly samples: ProgramStateAwardSample[]; +} + +export interface UsaSpendingClientOptions { + readonly fetchImpl?: typeof fetch; + readonly politenessDelayMs?: number; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Recent grant awards under one Assistance Listing to recipients in one + * state: total count plus a handful of sample recipients (review-page + * evidence — lets a human spot "one incumbent's renewals" vs "spread + * across orgs like ours"). + */ +export async function fetchProgramStateAwards( + aln: string, + state: string, + { startDate, endDate }: { startDate: string; endDate: string }, + options: UsaSpendingClientOptions = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + + const res = await fetchImpl(SEARCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filters: { + award_type_codes: GRANT_AWARD_TYPE_CODES, + recipient_locations: [{ country: 'USA', state }], + time_period: [{ start_date: startDate, end_date: endDate }], + program_numbers: [aln], + }, + fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'], + limit: 10, + page: 1, + }), + }); + if (!res.ok) { + throw new Error( + `USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`, + ); + } + + const body = (await res.json()) as { + results?: Array<{ + 'Recipient Name'?: string | null; + 'Award Amount'?: number | null; + 'Start Date'?: string | null; + }>; + page_metadata?: { total?: number | null }; + }; + + await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS); + + const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({ + recipientName: r['Recipient Name'] ?? 'Unknown recipient', + amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']), + startDate: r['Start Date'] ?? null, + })); + + return { + aln, + awardCount: body.page_metadata?.total ?? samples.length, + samples, + }; +} diff --git a/apps/outreach-worker/src/workflows/federal-precedent.ts b/apps/outreach-worker/src/workflows/federal-precedent.ts new file mode 100644 index 0000000..8b5f505 --- /dev/null +++ b/apps/outreach-worker/src/workflows/federal-precedent.ts @@ -0,0 +1,194 @@ +/** + * Nightly federal-precedent workflow — USASpending award history per + * Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index. + * + * For each distinct ALN across open federal grants: count grant awards to + * recipients in the target state over the last ~3 fiscal years, stamp the + * count + sample recipients onto every open grant carrying that ALN. + * Match retrieval feeds the count into the same 25-point precedent + * subscore foundations use — "this program funded N NH orgs recently" and + * "this foundation funded N NH orgs recently" are the same signal. + * + * Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before + * matching (05:15). Registration follows `ingest-grants.ts` exactly. + */ +import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk'; +import type { schema } from '@novelpad/outreach-core'; +import { + serverListOpenFederalAlns, + serverResetFederalPrecedent, + serverSetFederalPrecedent, +} from '@novelpad/outreach-core/server'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { + fetchProgramStateAwards, + type ProgramStateAwards, +} from '#~/sources/usaspending/client.js'; + +export type OutreachDb = NodePgDatabase; + +const RECIPIENT_STATE = 'NH'; +/** ~3 fiscal years of history — recent enough to predict, long enough to see a pattern. */ +const LOOKBACK_YEARS = 3; + +export interface FederalPrecedentDeps { + readonly db: OutreachDb; +} + +let registeredDeps: FederalPrecedentDeps | null = null; + +export function setFederalPrecedentDeps(deps: FederalPrecedentDeps): void { + registeredDeps = deps; +} + +function getFederalPrecedentDeps(): FederalPrecedentDeps { + if (registeredDeps == null) { + throw new Error( + 'FederalPrecedentDeps not registered. Call setFederalPrecedentDeps() before DBOS.launch().', + ); + } + return registeredDeps; +} + +async function listAlns(db: OutreachDb): Promise { + return serverListOpenFederalAlns(db); +} +const listAlnsStep = DBOS.registerStep(listAlns, { + name: 'listOpenFederalAlns', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function fetchAlnAwards( + aln: string, + startDate: string, + endDate: string, +): Promise { + return fetchProgramStateAwards(aln, RECIPIENT_STATE, { + startDate, + endDate, + }); +} +const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, { + name: 'fetchAlnStateAwards', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function applyPrecedent( + db: OutreachDb, + precedent: ProgramStateAwards, +): Promise { + await serverSetFederalPrecedent(db, { + aln: precedent.aln, + awardCount: precedent.awardCount, + samples: precedent.samples, + }); +} +const applyPrecedentStep = DBOS.registerStep(applyPrecedent, { + name: 'applyFederalPrecedent', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function resetPrecedent(db: OutreachDb): Promise { + await serverResetFederalPrecedent(db); +} +const resetPrecedentStep = DBOS.registerStep(resetPrecedent, { + name: 'resetFederalPrecedent', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function runFederalPrecedent(): Promise { + const { db } = getFederalPrecedentDeps(); + + const alns = await listAlnsStep(db); + if (alns.length === 0) { + console.log( + '[federal-precedent] no open federal grants carry ALNs yet (ingest refresh pending?)', + ); + return; + } + + const end = new Date(); + const start = new Date(end); + start.setFullYear(start.getFullYear() - LOOKBACK_YEARS); + const startDate = start.toISOString().slice(0, 10); + const endDate = end.toISOString().slice(0, 10); + + await resetPrecedentStep(db); + + let programsWithHistory = 0; + let failed = 0; + for (const aln of alns) { + try { + const precedent = await fetchAlnAwardsStep(aln, startDate, endDate); + if (precedent.awardCount > 0) { + await applyPrecedentStep(db, precedent); + programsWithHistory++; + } + } catch (err) { + failed++; + console.error(`[federal-precedent] ALN ${aln} failed:`, err); + } + } + + console.log( + `[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`, + ); + if (failed > 0 && failed / alns.length > 0.2) { + throw new Error( + `[federal-precedent] systemic failure: ${failed}/${alns.length} programs failed`, + ); + } +} + +const g = globalThis as unknown as { + __outreachFederalPrecedentRegistered?: boolean; + __outreachFederalPrecedentHandle?: ( + scheduledTime: Date, + startedAt: Date, + ) => Promise; +}; + +if (!g.__outreachFederalPrecedentRegistered) { + g.__outreachFederalPrecedentRegistered = true; + + const federalPrecedent = async (_scheduledTime: Date, _startedAt: Date) => { + try { + await runFederalPrecedent(); + } catch (err) { + console.error('[federal-precedent] pass failed:', err); + throw err; + } + }; + + // Must be registered as BOTH a workflow and a scheduled function, + // referencing the same function object — see ingest-grants.ts. + g.__outreachFederalPrecedentHandle = DBOS.registerWorkflow(federalPrecedent, { + name: 'federalPrecedent', + }); + DBOS.registerScheduled(federalPrecedent, { + crontab: '45 4 * * *', + name: 'federalPrecedent', + mode: SchedulerMode.ExactlyOncePerInterval, + }); +} + +/** + * Starts one durable run of this workflow immediately through DBOS — + * the exact production path (workflow + checkpointed steps), used by + * `run-once.ts` for supervised/manual passes. Requires deps injected and + * `DBOS.launch()` completed. + */ +export function runFederalPrecedentNow(): Promise { + const handle = g.__outreachFederalPrecedentHandle; + if (handle == null) { + throw new Error( + 'federalPrecedent is not registered; was this module imported before DBOS.launch()?', + ); + } + return handle(new Date(), new Date()); +} diff --git a/apps/outreach-worker/src/workflows/ingest-grants.ts b/apps/outreach-worker/src/workflows/ingest-grants.ts index 2f1f6aa..1fdbe8a 100644 --- a/apps/outreach-worker/src/workflows/ingest-grants.ts +++ b/apps/outreach-worker/src/workflows/ingest-grants.ts @@ -16,7 +16,7 @@ import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk'; import type { schema } from '@novelpad/outreach-core'; import { serverInsertGrants, - serverListGrantSourceUrls, + serverMapGrantVerification, type NewGrantInput, } from '@novelpad/outreach-core/server'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -100,11 +100,19 @@ async function fetchGrantDetails( // hits.slice(0, cap) re-fetched the same head of the search results // every night and never drained the backlog. Known opportunities fill // any remaining budget (refreshing close dates / lastVerifiedAt). - const known = await serverListGrantSourceUrls(db, 'grants_gov'); - const isKnown = (hit: GrantsGovSearchHit) => - known.has(`https://www.grants.gov/search-results-detail/${hit.id}`); - const fresh = hits.filter((h) => !isKnown(h)); - const refresh = hits.filter(isKnown); + const known = await serverMapGrantVerification(db, 'grants_gov'); + const urlOf = (hit: GrantsGovSearchHit) => + `https://www.grants.gov/search-results-detail/${hit.id}`; + const fresh = hits.filter((h) => !known.has(urlOf(h))); + // Oldest-verified first so refreshes rotate through the whole corpus + // instead of re-fetching the same head of the search results. + const refresh = hits + .filter((h) => known.has(urlOf(h))) + .sort( + (a, b) => + (known.get(urlOf(a))?.getTime() ?? 0) - + (known.get(urlOf(b))?.getTime() ?? 0), + ); const toFetch = [...fresh, ...refresh].slice(0, DETAIL_FETCH_CAP); console.log( `[ingest-grants] detail budget ${DETAIL_FETCH_CAP}: ${Math.min(fresh.length, DETAIL_FETCH_CAP)} new, ${Math.max(0, Math.min(DETAIL_FETCH_CAP - fresh.length, refresh.length))} refresh, backlog remaining ${Math.max(0, fresh.length - DETAIL_FETCH_CAP)}`, diff --git a/docs/features/scoring.md b/docs/features/scoring.md index fb8ddc9..ce3ca6e 100644 --- a/docs/features/scoring.md +++ b/docs/features/scoring.md @@ -21,3 +21,11 @@ Nightly `matchGrants` workflow (05:15 UTC, after embeddings) — the plan's "SQL - Rolling (null) deadlines now PASS the runway gate and score 2/5 runway. - Candidate orgs exclude NTEE `T*` grantmakers, and matches self-gate by funder EIN plus normalized-name fallback — the first precedent run's top "leads" were foundations matched to themselves (NHDOJ registers grantmakers as charities; several lack resolved EINs). - First full run: 747 NH foundations, 21 batches (~6.5GB processed, 1 deferred), 2,766+ grants-paid rows, 123 synthesized foundation grants → **89 easy wins across 27 orgs**, top hero 69/100 with real matches like AIDS Response-Seacoast → Foundation for Seacoast Health. Coverage grows nightly as enrichment drains the org backlog (56 candidate orgs of ~6.2K NH registrants so far). + +## v3 (2026-07-16): federal precedent via USASpending + +Funder precedent now covers federal grants at the **program (ALN/CFDA) level** — the criterion was always valid for federal funders; only the data was missing. Ingest captures each opportunity's ALN numbers (`grants.alns`); the nightly `federalPrecedent` workflow (04:45) queries USASpending's award search (free, official, no key) for grant awards to NH recipients over a 3-year lookback per distinct program, stamping `program_state_award_count` + sample recipients onto every open grant carrying that ALN (multi-ALN grants keep the highest count). Match retrieval feeds it through the same `funderStateGrantCount` input and 25-point tiers foundations use. The match detail page shows the recipients table ("Recent program awards to NH recipients") with an explicit caution: distinguish one incumbent's renewals from genuine spread across orgs. + +Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs. + +First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline). diff --git a/packages/outreach-core/drizzle/server/1784257140_federal-precedent.sql b/packages/outreach-core/drizzle/server/1784257140_federal-precedent.sql new file mode 100644 index 0000000..b738050 --- /dev/null +++ b/packages/outreach-core/drizzle/server/1784257140_federal-precedent.sql @@ -0,0 +1,3 @@ +ALTER TABLE "grants" ADD COLUMN "alns" text[];--> statement-breakpoint +ALTER TABLE "grants" ADD COLUMN "program_state_award_count" integer;--> statement-breakpoint +ALTER TABLE "grants" ADD COLUMN "program_state_awards" jsonb; \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json b/packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json new file mode 100644 index 0000000..6759f02 --- /dev/null +++ b/packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json @@ -0,0 +1,1478 @@ +{ + "id": "8723e7d9-ee26-468e-95b5-6d609eea60bc", + "prevId": "d30ecf3d-7041-41cc-a4e7-7d70f38b3218", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_status": { + "name": "email_status", + "type": "email_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "source_provider": { + "name": "source_provider", + "type": "contact_source_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "contact_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_contacts_org": { + "name": "idx_contacts_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_email": { + "name": "idx_contacts_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_org_id_orgs_id_fk": { + "name": "contacts_org_id_orgs_id_fk", + "tableFrom": "contacts", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funder_grants": { + "name": "funder_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder_id": { + "name": "funder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_city": { + "name": "recipient_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_state": { + "name": "recipient_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_year": { + "name": "tax_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funder_grants_funder": { + "name": "idx_funder_grants_funder", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_funder_year": { + "name": "idx_funder_grants_funder_year", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tax_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_recipient_state": { + "name": "idx_funder_grants_recipient_state", + "columns": [ + { + "expression": "recipient_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funder_grants_funder_id_funders_id_fk": { + "name": "funder_grants_funder_id_funders_id_fk", + "tableFrom": "funder_grants", + "tableTo": "funders", + "columnsFrom": [ + "funder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funders": { + "name": "funders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_assets": { + "name": "total_assets", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "application_info": { + "name": "application_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "latest_tax_year": { + "name": "latest_tax_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latest_object_id": { + "name": "latest_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funders_ein": { + "name": "idx_funders_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funders_state": { + "name": "idx_funders_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grants": { + "name": "grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligibility_entity_types": { + "name": "eligibility_entity_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "geographic_scope": { + "name": "geographic_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "program_areas": { + "name": "program_areas", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "award_floor": { + "name": "award_floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "award_ceiling": { + "name": "award_ceiling", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_awards_count": { + "name": "expected_awards_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "open_date": { + "name": "open_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_date": { + "name": "close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "match_requirement": { + "name": "match_requirement", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "application_effort_estimate": { + "name": "application_effort_estimate", + "type": "application_effort_estimate", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "application_form_supported": { + "name": "application_form_supported", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "funder_ein": { + "name": "funder_ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alns": { + "name": "alns", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "program_state_award_count": { + "name": "program_state_award_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "program_state_awards": { + "name": "program_state_awards", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "synopsis_embedding": { + "name": "synopsis_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_grants_source_url": { + "name": "idx_grants_source_url", + "columns": [ + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_status": { + "name": "idx_grants_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_close_date": { + "name": "idx_grants_close_date", + "columns": [ + { + "expression": "close_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_source": { + "name": "idx_grants_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_funder_ein": { + "name": "idx_grants_funder_ein", + "columns": [ + { + "expression": "funder_ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grants_synopsis_embedding_idx": { + "name": "grants_synopsis_embedding_idx", + "columns": [ + { + "expression": "synopsis_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subscores": { + "name": "subscores", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hard_gates_passed": { + "name": "hard_gates_passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "easy_win": { + "name": "easy_win", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rationale": { + "name": "rationale", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "match_review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reject_reason": { + "name": "reject_reason", + "type": "match_reject_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_hero": { + "name": "is_hero", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_matches_org_grant": { + "name": "idx_matches_org_grant", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_org": { + "name": "idx_matches_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_grant": { + "name": "idx_matches_grant", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_review_status": { + "name": "idx_matches_review_status", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_org_id_orgs_id_fk": { + "name": "matches_org_id_orgs_id_fk", + "tableFrom": "matches", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_grant_id_grants_id_fk": { + "name": "matches_grant_id_grants_id_fk", + "tableFrom": "matches", + "tableTo": "grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_profiles": { + "name": "org_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mission_statement": { + "name": "mission_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "programs": { + "name": "programs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "service_geography": { + "name": "service_geography", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recent_news": { + "name": "recent_news", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "known_funders": { + "name": "known_funders", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "staff": { + "name": "staff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "budget_band": { + "name": "budget_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "profile_embedding": { + "name": "profile_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_org_profiles_org_unique": { + "name": "idx_org_profiles_org_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_profiles_org": { + "name": "idx_org_profiles_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_profiles_embedding_idx": { + "name": "org_profiles_embedding_idx", + "columns": [ + { + "expression": "profile_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "org_profiles_org_id_orgs_id_fk": { + "name": "org_profiles_org_id_orgs_id_fk", + "tableFrom": "org_profiles", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NH'" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_revenue": { + "name": "total_revenue", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fiscal_year_end": { + "name": "fiscal_year_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_number": { + "name": "registration_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "registration_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "icp_band": { + "name": "icp_band", + "type": "icp_band", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "source_registry": { + "name": "source_registry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_orgs_ein": { + "name": "idx_orgs_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"ein\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_registry_reg_no": { + "name": "idx_orgs_registry_reg_no", + "columns": [ + { + "expression": "source_registry", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"source_registry\" IS NOT NULL AND \"orgs\".\"registration_number\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_icp_band": { + "name": "idx_orgs_icp_band", + "columns": [ + { + "expression": "icp_band", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_state": { + "name": "idx_orgs_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_events": { + "name": "pipeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "pipeline_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_pipeline_events_org": { + "name": "idx_pipeline_events_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_contact": { + "name": "idx_pipeline_events_contact", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_match": { + "name": "idx_pipeline_events_match", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_type_occurred": { + "name": "idx_pipeline_events_type_occurred", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_events_org_id_orgs_id_fk": { + "name": "pipeline_events_org_id_orgs_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_contact_id_contacts_id_fk": { + "name": "pipeline_events_contact_id_contacts_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_match_id_matches_id_fk": { + "name": "pipeline_events_match_id_matches_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_effort_estimate": { + "name": "application_effort_estimate", + "schema": "public", + "values": [ + "loi_only", + "short_form", + "full_federal", + "unknown" + ] + }, + "public.contact_priority": { + "name": "contact_priority", + "schema": "public", + "values": [ + "named", + "generic" + ] + }, + "public.contact_source_provider": { + "name": "contact_source_provider", + "schema": "public", + "values": [ + "apollo", + "irs_990", + "website", + "manual" + ] + }, + "public.email_status": { + "name": "email_status", + "schema": "public", + "values": [ + "unverified", + "valid", + "risky", + "invalid" + ] + }, + "public.grant_source": { + "name": "grant_source", + "schema": "public", + "values": [ + "grants_gov", + "nh_state", + "irs_990pf", + "pnd_rss", + "candid", + "manual" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "open", + "expired", + "closed" + ] + }, + "public.icp_band": { + "name": "icp_band", + "schema": "public", + "values": [ + "below", + "primary", + "above", + "unknown" + ] + }, + "public.match_reject_reason": { + "name": "match_reject_reason", + "schema": "public", + "values": [ + "wrong_eligibility", + "wrong_geography", + "bad_capacity_fit", + "weak_mission_fit", + "stale_deadline", + "bad_contact", + "other" + ] + }, + "public.match_review_status": { + "name": "match_review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "edited" + ] + }, + "public.pipeline_event_type": { + "name": "pipeline_event_type", + "schema": "public", + "values": [ + "enrolled", + "sent", + "opened", + "replied", + "bounced", + "unsubscribed", + "brief_requested", + "brief_sent", + "demo_booked", + "demo_held", + "pilot_started", + "converted" + ] + }, + "public.registration_status": { + "name": "registration_status", + "schema": "public", + "values": [ + "good_standing", + "lapsed", + "suspended", + "unknown" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/_journal.json b/packages/outreach-core/drizzle/server/meta/_journal.json index 5b11aab..b5f5174 100644 --- a/packages/outreach-core/drizzle/server/meta/_journal.json +++ b/packages/outreach-core/drizzle/server/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1784236406321, "tag": "1784236406_funder-precedent-index", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784257140142, + "tag": "1784257140_federal-precedent", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/outreach-core/src/db/schema.ts b/packages/outreach-core/src/db/schema.ts index 3d802f0..174bf8f 100644 --- a/packages/outreach-core/src/db/schema.ts +++ b/packages/outreach-core/src/db/schema.ts @@ -143,6 +143,16 @@ export const grants = pgTable( // Links 990-PF-synthesized grants to their foundation for the // precedent subscore; null for public-RFP sources. funderEin: text('funder_ein'), + // Federal Assistance Listing Numbers (CFDA), e.g. ['93.243'] — the + // join key into USASpending award history for federal precedent. + alns: text('alns').array(), + // Denormalized federal precedent: recent awards this grant's + // program(s) made to recipients in the target state (USASpending), + // refreshed by the federalPrecedent workflow. Null for non-federal + // sources and never touched by ingest upserts. + programStateAwardCount: integer('program_state_award_count'), + // Sample recipients backing the count — review-page evidence. + programStateAwards: jsonb('program_state_awards'), status: grantStatusEnum('status').notNull().default('open'), synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }), lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }), diff --git a/packages/outreach-core/src/grants/actions/index.server.ts b/packages/outreach-core/src/grants/actions/index.server.ts index 279e2b7..41637d3 100644 --- a/packages/outreach-core/src/grants/actions/index.server.ts +++ b/packages/outreach-core/src/grants/actions/index.server.ts @@ -2,3 +2,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'; +export * from './set-federal-precedent.server.js'; diff --git a/packages/outreach-core/src/grants/actions/insert-grants.server.ts b/packages/outreach-core/src/grants/actions/insert-grants.server.ts index dea9f45..0cafd98 100644 --- a/packages/outreach-core/src/grants/actions/insert-grants.server.ts +++ b/packages/outreach-core/src/grants/actions/insert-grants.server.ts @@ -47,6 +47,8 @@ export async function serverInsertGrants( applicationEffortEstimate: sql`excluded.application_effort_estimate`, applicationFormSupported: sql`excluded.application_form_supported`, source: sql`excluded.source`, + alns: sql`excluded.alns`, + funderEin: sql`excluded.funder_ein`, status: sql`excluded.status`, lastVerifiedAt: sql`excluded.last_verified_at`, updatedAt: sql`now()`, diff --git a/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts b/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts new file mode 100644 index 0000000..b539fc1 --- /dev/null +++ b/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts @@ -0,0 +1,50 @@ +import { and, eq, sql } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +export interface AlnPrecedent { + readonly aln: string; + readonly awardCount: number; + readonly samples: unknown; +} + +/** + * Applies per-program USASpending precedent onto every open federal grant + * carrying that ALN. Grants with multiple ALNs keep the HIGHEST count + * seen (a grant reachable through any strongly-NH program inherits that + * program's precedent), and samples follow whichever count won. + */ +export async function serverSetFederalPrecedent( + db: NpOutreachDatabase | NpOutreachTransaction, + precedent: AlnPrecedent, +): Promise { + await db + .update(schema.grants) + .set({ + programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`, + programStateAwards: sql`CASE + WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount} + THEN ${JSON.stringify(precedent.samples)}::jsonb + ELSE ${schema.grants.programStateAwards} + END`, + updatedAt: sql`now()`, + }) + .where( + and( + eq(schema.grants.source, 'grants_gov'), + eq(schema.grants.status, 'open'), + sql`${precedent.aln} = ANY(${schema.grants.alns})`, + ), + ); +} + +/** Zeroes precedent before a refresh pass so removed programs don't keep stale counts. */ +export async function serverResetFederalPrecedent( + db: NpOutreachDatabase | NpOutreachTransaction, +): Promise { + await db + .update(schema.grants) + .set({ programStateAwardCount: null, programStateAwards: null }) + .where(eq(schema.grants.source, 'grants_gov')); +} diff --git a/packages/outreach-core/src/grants/queries/index.server.ts b/packages/outreach-core/src/grants/queries/index.server.ts index b8b27f0..ca00d06 100644 --- a/packages/outreach-core/src/grants/queries/index.server.ts +++ b/packages/outreach-core/src/grants/queries/index.server.ts @@ -2,3 +2,4 @@ export * from './list-open-grants.server.js'; export * from './list-grants-needing-embedding.server.js'; export * from './list-eligible-grants-for-org.server.js'; export * from './list-grant-source-urls.server.js'; +export * from './list-federal-alns.server.js'; diff --git a/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts b/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts index 9258e16..4475fb3 100644 --- a/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts +++ b/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts @@ -62,12 +62,15 @@ export async function serverListEligibleGrantsForOrg( applicationFormSupported: schema.grants.applicationFormSupported, funderEin: schema.grants.funderEin, similarity: sql`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`, - funderStateGrantCount: sql`( - SELECT count(*)::int FROM funder_grants fg - JOIN funders f ON fg.funder_id = f.id - WHERE f.ein = ${schema.grants.funderEin} - AND fg.recipient_state = ${filters.orgState} - )`, + funderStateGrantCount: sql`CASE + WHEN ${schema.grants.funderEin} IS NOT NULL THEN ( + SELECT count(*)::int FROM funder_grants fg + JOIN funders f ON fg.funder_id = f.id + WHERE f.ein = ${schema.grants.funderEin} + AND fg.recipient_state = ${filters.orgState} + ) + ELSE ${schema.grants.programStateAwardCount} + END`, }) .from(schema.grants) .where( diff --git a/packages/outreach-core/src/grants/queries/list-federal-alns.server.ts b/packages/outreach-core/src/grants/queries/list-federal-alns.server.ts new file mode 100644 index 0000000..bc30ba3 --- /dev/null +++ b/packages/outreach-core/src/grants/queries/list-federal-alns.server.ts @@ -0,0 +1,25 @@ +import { and, eq, isNotNull, sql } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +/** + * Distinct ALN (CFDA) numbers across open federal grants — the work list + * for the federalPrecedent workflow. A few hundred programs cover the + * whole corpus, so precedent is fetched per program, not per grant. + */ +export async function serverListOpenFederalAlns( + db: NpOutreachDatabase | NpOutreachTransaction, +): Promise { + const rows = await db + .select({ aln: sql`DISTINCT unnest(${schema.grants.alns})` }) + .from(schema.grants) + .where( + and( + eq(schema.grants.source, 'grants_gov'), + eq(schema.grants.status, 'open'), + isNotNull(schema.grants.alns), + ), + ); + return rows.map((r) => r.aln).sort(); +} diff --git a/packages/outreach-core/src/grants/queries/list-grant-source-urls.server.ts b/packages/outreach-core/src/grants/queries/list-grant-source-urls.server.ts index 75d7dde..0dac5f4 100644 --- a/packages/outreach-core/src/grants/queries/list-grant-source-urls.server.ts +++ b/packages/outreach-core/src/grants/queries/list-grant-source-urls.server.ts @@ -4,17 +4,30 @@ import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; import { schema } from '#~/db/db.js'; /** - * All source URLs already ingested for one source — lets ingestion spend - * its per-run detail-fetch budget on NEW opportunities first instead of - * re-fetching the same head of the search results every night. + * Source URL → lastVerifiedAt for everything already ingested from one + * source. Ingestion spends its per-run detail budget on NEW opportunities + * first, then refreshes KNOWN ones oldest-verified-first — a plain Set + * caused the refresh half to re-fetch the same head of the search results + * every pass. */ +export async function serverMapGrantVerification( + db: NpOutreachDatabase | NpOutreachTransaction, + source: (typeof schema.grants.$inferSelect)['source'], +): Promise> { + const rows = await db + .select({ + sourceUrl: schema.grants.sourceUrl, + lastVerifiedAt: schema.grants.lastVerifiedAt, + }) + .from(schema.grants) + .where(eq(schema.grants.source, source)); + return new Map(rows.map((r) => [r.sourceUrl, r.lastVerifiedAt])); +} + +/** @deprecated superseded by serverMapGrantVerification; kept for callers needing only membership. */ export async function serverListGrantSourceUrls( db: NpOutreachDatabase | NpOutreachTransaction, source: (typeof schema.grants.$inferSelect)['source'], ): Promise> { - const rows = await db - .select({ sourceUrl: schema.grants.sourceUrl }) - .from(schema.grants) - .where(eq(schema.grants.source, source)); - return new Set(rows.map((r) => r.sourceUrl)); + return new Set((await serverMapGrantVerification(db, source)).keys()); } 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 57d39db..8b1158b 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 @@ -88,6 +88,7 @@ export async function serverGetMatchDetail( grantEligibility: schema.grants.eligibilityEntityTypes, grantGeo: schema.grants.geographicScope, grantEffort: schema.grants.applicationEffortEstimate, + grantProgramAwards: schema.grants.programStateAwards, }) .from(schema.matches) .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) @@ -109,6 +110,23 @@ export async function serverGetMatchDetail( let funderGivingHistory: MatchDetail['funderGivingHistory'] = []; let funderApplicationInfo: unknown = null; + if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) { + // Federal grants: USASpending program awards fill the same evidence + // table (recipient/amount/year) the 990-PF history uses. + funderGivingHistory = ( + row.grantProgramAwards as Array<{ + recipientName?: string; + amount?: number | null; + startDate?: string | null; + }> + ).map((a) => ({ + recipientName: a.recipientName ?? 'Unknown recipient', + recipientCity: null, + amount: a.amount ?? null, + purpose: null, + taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0, + })); + } if (row.grantFunderEin != null) { const funderRow = await db .select({ applicationInfo: schema.funders.applicationInfo })