feat: scaffold outreach engine monorepo on the novelpad-desktop stack

Workspaces: config (copied), outreach-core (schema + actions/queries +
hard gates), outreach-ai (Gemini client + embeddings copies, profiler and
mission-fit-judge agent stubs), outreach-worker (DBOS executor with
nightly ingest + hourly expiry workflows), outreach-review (RR7 review
queue v0). Initial drizzle migration incl. pgvector extension.

Stack contract: Yarn 4.5.0 + Turbo, Node 22.16, Drizzle 0.44.6 +
pgvector, DBOS 4.17.6, @google/genai on Vertex, gemini-embedding-001
@1536, React Router v7. Files copied from novelpad-desktop carry
provenance headers @ 62c56b87.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-16 11:08:24 -04:00
commit 14200edb60
80 changed files with 11637 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { PgQueryResultHKT, PgTransaction } from 'drizzle-orm/pg-core';
import { schema } from './schema.js';
export { schema };
/**
* The outreach-core database handle. This package is server-only — there is
* no client/local database, so unlike @novelpad/core there is only ever one
* of these (no `NpClientDatabase` counterpart).
*/
export type NpOutreachDatabase = NodePgDatabase<typeof schema>;
export type NpOutreachTransaction<
TQueryResult extends PgQueryResultHKT = PgQueryResultHKT,
> = PgTransaction<TQueryResult, typeof schema>;

View File

@@ -0,0 +1,2 @@
export * from './db.js';
export * from './schema.js';

View File

@@ -0,0 +1,339 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
real,
text,
timestamp,
uniqueIndex,
uuid,
vector,
} from 'drizzle-orm/pg-core';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const applicationEffortEnum = pgEnum('application_effort_estimate', [
'loi_only',
'short_form',
'full_federal',
'unknown',
]);
export const grantSourceEnum = pgEnum('grant_source', [
'grants_gov',
'nh_state',
'irs_990pf',
'pnd_rss',
'candid',
'manual',
]);
export const grantStatusEnum = pgEnum('grant_status', [
'open',
'expired',
'closed',
]);
export const registrationStatusEnum = pgEnum('registration_status', [
'good_standing',
'lapsed',
'unknown',
]);
export const icpBandEnum = pgEnum('icp_band', [
'below',
'primary',
'above',
'unknown',
]);
export const emailStatusEnum = pgEnum('email_status', [
'unverified',
'valid',
'risky',
'invalid',
]);
export const contactSourceEnum = pgEnum('contact_source_provider', [
'apollo',
'irs_990',
'website',
'manual',
]);
export const contactPriorityEnum = pgEnum('contact_priority', [
'named',
'generic',
]);
export const matchReviewStatusEnum = pgEnum('match_review_status', [
'pending',
'approved',
'rejected',
'edited',
]);
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
'wrong_eligibility',
'wrong_geography',
'bad_capacity_fit',
'weak_mission_fit',
'stale_deadline',
'bad_contact',
'other',
]);
export const pipelineEventTypeEnum = pgEnum('pipeline_event_type', [
'enrolled',
'sent',
'opened',
'replied',
'bounced',
'unsubscribed',
'brief_requested',
'brief_sent',
'demo_booked',
'demo_held',
'pilot_started',
'converted',
]);
// ---------------------------------------------------------------------------
// grants — open grant opportunities ingested from Grants.gov, NH state
// sources, IRS 990-PF filings, Philanthropy News Digest RSS, and Candid.
// ---------------------------------------------------------------------------
export const grants = pgTable(
'grants',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
funder: text('funder').notNull(),
title: text('title').notNull(),
synopsis: text('synopsis'),
eligibilityEntityTypes: text('eligibility_entity_types').array(),
geographicScope: text('geographic_scope'),
// NTEE codes, e.g. ['A', 'B20', 'P']
programAreas: text('program_areas').array(),
awardFloor: integer('award_floor'),
awardCeiling: integer('award_ceiling'),
expectedAwardsCount: integer('expected_awards_count'),
openDate: timestamp('open_date', { withTimezone: true }),
closeDate: timestamp('close_date', { withTimezone: true }),
matchRequirement: boolean('match_requirement').notNull().default(false),
applicationEffortEstimate: applicationEffortEnum(
'application_effort_estimate',
)
.notNull()
.default('unknown'),
applicationFormSupported: boolean('application_form_supported')
.notNull()
.default(false),
sourceUrl: text('source_url').notNull(),
source: grantSourceEnum('source').notNull(),
status: grantStatusEnum('status').notNull().default('open'),
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
},
(t) => [
// Upsert key: sources are re-crawled and re-ingested on a schedule.
uniqueIndex('idx_grants_source_url').on(t.sourceUrl),
index('idx_grants_status').on(t.status),
index('idx_grants_close_date').on(t.closeDate),
index('idx_grants_source').on(t.source),
index('grants_synopsis_embedding_idx').using(
'hnsw',
t.synopsisEmbedding.op('vector_cosine_ops'),
),
],
);
// ---------------------------------------------------------------------------
// orgs — New Hampshire nonprofits being profiled and matched against grants.
// ---------------------------------------------------------------------------
export const orgs = pgTable(
'orgs',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
name: text('name').notNull(),
city: text('city'),
state: text('state').notNull().default('NH'),
ein: text('ein'),
nteeCode: text('ntee_code'),
totalRevenue: integer('total_revenue'),
fiscalYearEnd: text('fiscal_year_end'),
registrationStatus: registrationStatusEnum('registration_status')
.notNull()
.default('unknown'),
icpBand: icpBandEnum('icp_band').notNull().default('unknown'),
sourceRegistry: text('source_registry'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
},
(t) => [
// Partial unique index: EIN is unique when present, but many small
// orgs in early ingestion won't have one resolved yet.
uniqueIndex('idx_orgs_ein').on(t.ein).where(sql`${t.ein} IS NOT NULL`),
index('idx_orgs_icp_band').on(t.icpBand),
index('idx_orgs_state').on(t.state),
],
);
// ---------------------------------------------------------------------------
// org_profiles — LLM/agent-researched enrichment for an org.
// ---------------------------------------------------------------------------
export const orgProfiles = pgTable(
'org_profiles',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
orgId: uuid('org_id')
.notNull()
.references(() => orgs.id, { onDelete: 'cascade' }),
missionStatement: text('mission_statement'),
programs: jsonb('programs'),
serviceGeography: text('service_geography'),
recentNews: jsonb('recent_news'),
knownFunders: jsonb('known_funders'),
staff: jsonb('staff'),
budgetBand: text('budget_band'),
// Citation URLs backing the researched fields above.
sources: jsonb('sources'),
confidence: real('confidence'),
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
},
(t) => [
index('idx_org_profiles_org').on(t.orgId),
index('org_profiles_embedding_idx').using(
'hnsw',
t.profileEmbedding.op('vector_cosine_ops'),
),
],
);
// ---------------------------------------------------------------------------
// contacts — people at an org, sourced from Apollo, 990 filings, or the
// org's own website.
// ---------------------------------------------------------------------------
export const contacts = pgTable(
'contacts',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
orgId: uuid('org_id')
.notNull()
.references(() => orgs.id, { onDelete: 'cascade' }),
fullName: text('full_name'),
title: text('title'),
email: text('email'),
emailStatus: emailStatusEnum('email_status').notNull().default('unverified'),
sourceProvider: contactSourceEnum('source_provider'),
priority: contactPriorityEnum('priority').notNull().default('generic'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
},
(t) => [
index('idx_contacts_org').on(t.orgId),
index('idx_contacts_email').on(t.email),
],
);
// ---------------------------------------------------------------------------
// matches — a scored (org, grant) pair, gated deterministically and then
// weighted by LLM subscores, queued for human review.
// ---------------------------------------------------------------------------
export const matches = pgTable(
'matches',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
orgId: uuid('org_id')
.notNull()
.references(() => orgs.id, { onDelete: 'cascade' }),
grantId: uuid('grant_id')
.notNull()
.references(() => grants.id, { onDelete: 'cascade' }),
totalScore: integer('total_score').notNull(),
subscores: jsonb('subscores'),
hardGatesPassed: boolean('hard_gates_passed').notNull().default(false),
easyWin: boolean('easy_win').notNull().default(false),
// LLM-produced citations backing the score/subscores.
rationale: jsonb('rationale'),
reviewStatus: matchReviewStatusEnum('review_status')
.notNull()
.default('pending'),
rejectReason: matchRejectReasonEnum('reject_reason'),
isHero: boolean('is_hero').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
},
(t) => [
index('idx_matches_org').on(t.orgId),
index('idx_matches_grant').on(t.grantId),
index('idx_matches_review_status').on(t.reviewStatus),
],
);
// ---------------------------------------------------------------------------
// pipeline_events — outreach lifecycle events synced back from Apollo (and
// recorded internally) for an org/contact/match.
// ---------------------------------------------------------------------------
export const pipelineEvents = pgTable(
'pipeline_events',
{
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
orgId: uuid('org_id').references(() => orgs.id, { onDelete: 'set null' }),
contactId: uuid('contact_id').references(() => contacts.id, {
onDelete: 'set null',
}),
matchId: uuid('match_id').references(() => matches.id, {
onDelete: 'set null',
}),
eventType: pipelineEventTypeEnum('event_type').notNull(),
payload: jsonb('payload'),
occurredAt: timestamp('occurred_at', { withTimezone: true })
.notNull()
.defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
},
(t) => [
index('idx_pipeline_events_org').on(t.orgId),
index('idx_pipeline_events_contact').on(t.contactId),
index('idx_pipeline_events_match').on(t.matchId),
index('idx_pipeline_events_type_occurred').on(t.eventType, t.occurredAt),
],
);
// ---------------------------------------------------------------------------
// Schema barrel
// ---------------------------------------------------------------------------
export const schema = {
grants,
orgs,
orgProfiles,
contacts,
matches,
pipelineEvents,
};

View File

@@ -0,0 +1,28 @@
import { and, eq, lt, sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* Marks every currently-`open` grant whose `closeDate` has passed as
* `expired`, so it drops out of the active match/scoring pool. Grants with
* no `closeDate` (rolling/LOI-only programs) are left untouched.
*
* Returns the ids that were flipped, for logging/observability.
*/
export async function serverExpireClosedGrants(
db: NpOutreachDatabase | NpOutreachTransaction,
): Promise<string[]> {
const rows = await db
.update(schema.grants)
.set({ status: 'expired', updatedAt: sql`now()` })
.where(
and(
eq(schema.grants.status, 'open'),
lt(schema.grants.closeDate, sql`now()`),
),
)
.returning({ id: schema.grants.id });
return rows.map((r) => r.id);
}

View File

@@ -0,0 +1,2 @@
export * from './expire-closed-grants.server.js';
export * from './insert-grants.server.js';

View File

@@ -0,0 +1,55 @@
import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* Insertable shape for a single grant, as produced by a source-specific
* ingestion step (Grants.gov, NH state, 990-PF extracts, ...) after
* normalization to our schema's columns.
*/
export type NewGrantInput = Omit<
typeof schema.grants.$inferInsert,
'id' | 'createdAt' | 'updatedAt'
>;
/**
* Upserts a batch of grants, keyed on `sourceUrl` — the stable identifier
* across re-crawls of the same upstream listing. Existing rows are
* refreshed in place (title/dates/amounts can change between crawls);
* `status` is also re-set from the incoming payload so a grant that was
* re-opened upstream comes back out of `expired`/`closed`.
*/
export async function serverInsertGrants(
db: NpOutreachDatabase | NpOutreachTransaction,
grants: ReadonlyArray<NewGrantInput>,
): Promise<void> {
if (grants.length === 0) return;
await db
.insert(schema.grants)
.values([...grants])
.onConflictDoUpdate({
target: schema.grants.sourceUrl,
set: {
funder: sql`excluded.funder`,
title: sql`excluded.title`,
synopsis: sql`excluded.synopsis`,
eligibilityEntityTypes: sql`excluded.eligibility_entity_types`,
geographicScope: sql`excluded.geographic_scope`,
programAreas: sql`excluded.program_areas`,
awardFloor: sql`excluded.award_floor`,
awardCeiling: sql`excluded.award_ceiling`,
expectedAwardsCount: sql`excluded.expected_awards_count`,
openDate: sql`excluded.open_date`,
closeDate: sql`excluded.close_date`,
matchRequirement: sql`excluded.match_requirement`,
applicationEffortEstimate: sql`excluded.application_effort_estimate`,
applicationFormSupported: sql`excluded.application_form_supported`,
source: sql`excluded.source`,
status: sql`excluded.status`,
lastVerifiedAt: sql`excluded.last_verified_at`,
updatedAt: sql`now()`,
},
});
}

View File

@@ -0,0 +1,2 @@
export * from './actions/index.server.js';
export * from './queries/index.server.js';

View File

@@ -0,0 +1 @@
export * from './list-open-grants.server.js';

View File

@@ -0,0 +1,15 @@
import { eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* All grants currently in `open` status, most-recently-closing first isn't
* assumed here — callers needing a particular ordering (e.g. soonest
* deadline) should `.orderBy` on the returned query builder.
*/
export function serverListOpenGrants(
db: NpOutreachDatabase | NpOutreachTransaction,
) {
return db.select().from(schema.grants).where(eq(schema.grants.status, 'open'));
}

View File

@@ -0,0 +1,6 @@
export * from './index.js';
export * from './grants/index.server.js';
export * from './matches/index.server.js';
export * from './orgs/index.server.js';
export * from './pipeline/index.server.js';

View File

@@ -0,0 +1,9 @@
// Client-safe barrel: schema, DB types, and pure domain logic only. This
// package is server-only (no client/local database), so unlike
// @novelpad/core there is no synced/client schema here — this export
// exists purely so consumers that only need the table definitions/types
// (e.g. a Drizzle `drizzle(pool, { schema })` construction site, or a
// worker sharing types with an action call) don't have to pull in every
// server-only action/query.
export * from './db/index.js';
export * from './matches/hard-gates.js';

View File

@@ -0,0 +1,2 @@
export * from './insert-match.server.js';
export * from './set-match-review.server.js';

View File

@@ -0,0 +1,28 @@
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
export type NewMatchInput = Omit<
typeof schema.matches.$inferInsert,
'id' | 'createdAt' | 'updatedAt' | 'reviewStatus'
>;
/**
* Records a scored (org, grant) match. Always lands in `reviewStatus:
* 'pending'` — the review decision is a separate step via
* `serverSetMatchReview`.
*/
export async function serverInsertMatch(
db: NpOutreachDatabase | NpOutreachTransaction,
match: NewMatchInput,
): Promise<string> {
const [row] = await db
.insert(schema.matches)
.values({ ...match, reviewStatus: 'pending' })
.returning({ id: schema.matches.id });
if (row == null) {
throw new Error('serverInsertMatch: insert returned no row');
}
return row.id;
}

View File

@@ -0,0 +1,35 @@
import { eq, sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
type ReviewStatus = (typeof schema.matches.$inferSelect)['reviewStatus'];
type RejectReason = (typeof schema.matches.$inferSelect)['rejectReason'];
/**
* Records a human reviewer's decision on a match. `rejectReason` is only
* meaningful (and only ever set) when `reviewStatus` is `'rejected'` — any
* other status clears it, so a previously-rejected match doesn't carry a
* stale reason if it's later re-approved after editing.
*/
export async function serverSetMatchReview(
db: NpOutreachDatabase | NpOutreachTransaction,
{
matchId,
reviewStatus,
rejectReason,
}: {
matchId: string;
reviewStatus: ReviewStatus;
rejectReason?: RejectReason;
},
): Promise<void> {
await db
.update(schema.matches)
.set({
reviewStatus,
rejectReason: reviewStatus === 'rejected' ? (rejectReason ?? null) : null,
updatedAt: sql`now()`,
})
.where(eq(schema.matches.id, matchId));
}

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import {
evaluateHardGates,
type HardGateGrantInput,
type HardGateOrgInput,
MIN_AWARD_CEILING,
} from './hard-gates.js';
const NOW = new Date('2026-07-16T00:00:00Z');
function daysFromNow(days: number): Date {
return new Date(NOW.getTime() + days * 24 * 60 * 60 * 1000);
}
const org: HardGateOrgInput = {
entityType: '501c3',
state: 'NH',
};
const grant: HardGateGrantInput = {
eligibilityEntityTypes: ['501c3', 'municipality'],
geographicScope: 'New Hampshire',
closeDate: daysFromNow(30),
awardCeiling: 50_000,
applicationFormSupported: true,
};
describe('evaluateHardGates', () => {
it('passes a fully-eligible match with no failures', () => {
const result = evaluateHardGates(org, grant, { now: NOW });
expect(result).toEqual({ passed: true, failures: [] });
});
it('treats an unrestricted entity-type list as eligible', () => {
const result = evaluateHardGates(
org,
{ ...grant, eligibilityEntityTypes: null },
{ now: NOW },
);
expect(result.failures).not.toContain('ineligible_entity_type');
});
it('fails when the org entity type is not in the eligibility list', () => {
const result = evaluateHardGates(
{ ...org, entityType: 'llc' },
grant,
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('ineligible_entity_type');
});
it('passes a national-scope grant regardless of org state', () => {
const result = evaluateHardGates(
org,
{ ...grant, geographicScope: 'National' },
{ now: NOW },
);
expect(result.failures).not.toContain('geography_mismatch');
});
it('fails when the geographic scope excludes the org state', () => {
const result = evaluateHardGates(
org,
{ ...grant, geographicScope: 'California' },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('geography_mismatch');
});
it('fails when the deadline is fewer than 21 days out', () => {
const result = evaluateHardGates(
org,
{ ...grant, closeDate: daysFromNow(20) },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('deadline_too_soon');
});
it('passes when the deadline is exactly 21 days out', () => {
const result = evaluateHardGates(
org,
{ ...grant, closeDate: daysFromNow(21) },
{ now: NOW },
);
expect(result.failures).not.toContain('deadline_too_soon');
});
it('fails when there is no close date at all', () => {
const result = evaluateHardGates(
org,
{ ...grant, closeDate: null },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('deadline_too_soon');
});
it('fails when the award ceiling is below the minimum', () => {
const result = evaluateHardGates(
org,
{ ...grant, awardCeiling: MIN_AWARD_CEILING - 1 },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('award_ceiling_too_low');
});
it('fails when the award ceiling is unknown', () => {
const result = evaluateHardGates(
org,
{ ...grant, awardCeiling: null },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('award_ceiling_too_low');
});
it('fails when the application form is not supported', () => {
const result = evaluateHardGates(
org,
{ ...grant, applicationFormSupported: false },
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toContain('application_form_unsupported');
});
it('accumulates every failing gate, not just the first', () => {
const result = evaluateHardGates(
{ entityType: 'llc', state: 'NH' },
{
eligibilityEntityTypes: ['501c3'],
geographicScope: 'California',
closeDate: null,
awardCeiling: null,
applicationFormSupported: false,
},
{ now: NOW },
);
expect(result.passed).toBe(false);
expect(result.failures).toEqual([
'ineligible_entity_type',
'geography_mismatch',
'deadline_too_soon',
'award_ceiling_too_low',
'application_form_unsupported',
]);
});
});

View File

@@ -0,0 +1,173 @@
/**
* Deterministic SQL-equivalent hard gates for an (org, grant) match.
*
* These are cheap, unambiguous pass/fail checks evaluated BEFORE the
* weighted LLM subscoring pass — a match that fails any hard gate is never
* worth spending an LLM call on. Kept as a pure function over plain data
* (no DB dependency) so it can be unit tested directly and reused from a
* SQL-backed batch job, a single-match rescoring step, or a preview in the
* review UI without threading a database handle through it.
*/
export interface HardGateOrgInput {
/** e.g. '501c3', 'municipality', 'school_district' */
readonly entityType: string;
/** Two-letter state code, e.g. 'NH'. */
readonly state: string;
}
export interface HardGateGrantInput {
/** Entity types the funder will accept; null/empty means unrestricted. */
readonly eligibilityEntityTypes: readonly string[] | null;
/** Free-text geographic scope, e.g. 'NH', 'New England', 'National'. */
readonly geographicScope: string | null;
readonly closeDate: Date | null;
readonly awardCeiling: number | null;
readonly applicationFormSupported: boolean;
}
export type HardGateFailureReason =
| 'ineligible_entity_type'
| 'geography_mismatch'
| 'deadline_too_soon'
| 'award_ceiling_too_low'
| 'application_form_unsupported';
export interface HardGateResult {
readonly passed: boolean;
readonly failures: readonly HardGateFailureReason[];
}
/** A match's runway must clear this many days before the close date. */
export const MIN_DAYS_TO_DEADLINE = 21;
/** Grants below this ceiling aren't worth the outreach effort. */
export const MIN_AWARD_CEILING = 10_000;
const NATIONAL_SCOPE_KEYWORDS = [
'national',
'nationwide',
'united states',
'usa',
];
/** Full state names, for scopes that spell the state out ('New Hampshire'). */
const STATE_NAMES: Record<string, string> = {
AL: 'alabama',
AK: 'alaska',
AZ: 'arizona',
AR: 'arkansas',
CA: 'california',
CO: 'colorado',
CT: 'connecticut',
DE: 'delaware',
FL: 'florida',
GA: 'georgia',
HI: 'hawaii',
ID: 'idaho',
IL: 'illinois',
IN: 'indiana',
IA: 'iowa',
KS: 'kansas',
KY: 'kentucky',
LA: 'louisiana',
ME: 'maine',
MD: 'maryland',
MA: 'massachusetts',
MI: 'michigan',
MN: 'minnesota',
MS: 'mississippi',
MO: 'missouri',
MT: 'montana',
NE: 'nebraska',
NV: 'nevada',
NH: 'new hampshire',
NJ: 'new jersey',
NM: 'new mexico',
NY: 'new york',
NC: 'north carolina',
ND: 'north dakota',
OH: 'ohio',
OK: 'oklahoma',
OR: 'oregon',
PA: 'pennsylvania',
RI: 'rhode island',
SC: 'south carolina',
SD: 'south dakota',
TN: 'tennessee',
TX: 'texas',
UT: 'utah',
VT: 'vermont',
VA: 'virginia',
WA: 'washington',
WV: 'west virginia',
WI: 'wisconsin',
WY: 'wyoming',
DC: 'district of columbia',
};
/**
* Runs every hard gate against an (org, grant) pair and returns which, if
* any, failed. `now` is injectable for deterministic testing.
*/
export function evaluateHardGates(
org: HardGateOrgInput,
grant: HardGateGrantInput,
{ now = new Date() }: { now?: Date } = {},
): HardGateResult {
const failures: HardGateFailureReason[] = [];
if (!isEntityEligible(org, grant)) failures.push('ineligible_entity_type');
if (!isGeographyEligible(org, grant)) failures.push('geography_mismatch');
if (!hasSufficientRunway(grant, now)) failures.push('deadline_too_soon');
if (!meetsAwardCeiling(grant)) failures.push('award_ceiling_too_low');
if (!grant.applicationFormSupported) {
failures.push('application_form_unsupported');
}
return { passed: failures.length === 0, failures };
}
function isEntityEligible(
org: HardGateOrgInput,
grant: HardGateGrantInput,
): boolean {
if (
grant.eligibilityEntityTypes == null ||
grant.eligibilityEntityTypes.length === 0
) {
return true;
}
return grant.eligibilityEntityTypes.some(
(entityType) => entityType.toLowerCase() === org.entityType.toLowerCase(),
);
}
function isGeographyEligible(
org: HardGateOrgInput,
grant: HardGateGrantInput,
): boolean {
const scope = grant.geographicScope?.trim().toLowerCase();
if (!scope) return true;
if (NATIONAL_SCOPE_KEYWORDS.some((keyword) => scope.includes(keyword))) {
return true;
}
// Match the two-letter code only on word boundaries ('NH', 'NH-only') so
// it can't fire on letters embedded inside another word, and also accept
// the spelled-out state name ('New Hampshire').
const code = org.state.trim().toLowerCase();
if (new RegExp(`\\b${code}\\b`, 'i').test(scope)) return true;
const fullName = STATE_NAMES[org.state.trim().toUpperCase()];
return fullName != null && scope.includes(fullName);
}
function hasSufficientRunway(grant: HardGateGrantInput, now: Date): boolean {
if (grant.closeDate == null) return false;
const msPerDay = 24 * 60 * 60 * 1000;
const daysRemaining = (grant.closeDate.getTime() - now.getTime()) / msPerDay;
return daysRemaining >= MIN_DAYS_TO_DEADLINE;
}
function meetsAwardCeiling(grant: HardGateGrantInput): boolean {
return grant.awardCeiling != null && grant.awardCeiling >= MIN_AWARD_CEILING;
}

View File

@@ -0,0 +1,6 @@
// `hard-gates.js` is client-safe (pure, no DB) and is exported from the
// package's root `index.js` instead — not re-exported here to avoid an
// ambiguous `export *` collision in `index.server.ts` (which re-exports
// both `./index.js` and this file).
export * from './actions/index.server.js';
export * from './queries/index.server.js';

View File

@@ -0,0 +1 @@
export * from './list-pending-review-matches.server.js';

View File

@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* Pending matches joined to their org and grant — the shape the review
* queue renders. One row per pending (org, grant) match, most useful
* columns only; the full match row (subscores, rationale) can be fetched
* by id when a detail view lands.
*/
export interface PendingReviewMatch {
id: string;
orgName: string;
grantTitle: string;
funder: string;
totalScore: number;
easyWin: boolean;
isHero: boolean;
closeDate: Date | null;
}
export async function serverListPendingReviewMatches(
db: NpOutreachDatabase | NpOutreachTransaction,
): Promise<PendingReviewMatch[]> {
return db
.select({
id: schema.matches.id,
orgName: schema.orgs.name,
grantTitle: schema.grants.title,
funder: schema.grants.funder,
totalScore: schema.matches.totalScore,
easyWin: schema.matches.easyWin,
isHero: schema.matches.isHero,
closeDate: schema.grants.closeDate,
})
.from(schema.matches)
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
.where(eq(schema.matches.reviewStatus, 'pending'));
}

View File

@@ -0,0 +1 @@
export * from './insert-org.server.js';

View File

@@ -0,0 +1,30 @@
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
export type NewOrgInput = Omit<
typeof schema.orgs.$inferInsert,
'id' | 'createdAt' | 'updatedAt'
>;
/**
* Inserts a single NH nonprofit org. Callers that discover an org from a
* registry re-scan should query for an existing `ein` match first — this
* function does not upsert (EIN is only unique when present, which makes a
* blind upsert-on-conflict ambiguous for the common case of an org sourced
* without one).
*/
export async function serverInsertOrg(
db: NpOutreachDatabase | NpOutreachTransaction,
org: NewOrgInput,
): Promise<string> {
const [row] = await db
.insert(schema.orgs)
.values(org)
.returning({ id: schema.orgs.id });
if (row == null) {
throw new Error('serverInsertOrg: insert returned no row');
}
return row.id;
}

View File

@@ -0,0 +1,2 @@
export * from './actions/index.server.js';
export * from './queries/index.server.js';

View File

@@ -0,0 +1 @@
export * from './list-orgs-in-icp-band.server.js';

View File

@@ -0,0 +1,14 @@
import { eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
type IcpBand = (typeof schema.orgs.$inferSelect)['icpBand'];
/** All orgs currently classified into the given ICP band. */
export function serverListOrgsInIcpBand(
db: NpOutreachDatabase | NpOutreachTransaction,
{ icpBand }: { icpBand: IcpBand },
) {
return db.select().from(schema.orgs).where(eq(schema.orgs.icpBand, icpBand));
}

View File

@@ -0,0 +1 @@
export * from './record-pipeline-event.server.js';

View File

@@ -0,0 +1,29 @@
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
export type NewPipelineEventInput = Omit<
typeof schema.pipelineEvents.$inferInsert,
'id' | 'createdAt'
>;
/**
* Records an outreach lifecycle event (Apollo webhook, or an internally
* generated milestone like `brief_requested`). `occurredAt` defaults to now
* when the caller doesn't have an authoritative upstream timestamp (e.g. a
* webhook payload that only carries the event, not the original send time).
*/
export async function serverRecordPipelineEvent(
db: NpOutreachDatabase | NpOutreachTransaction,
event: NewPipelineEventInput,
): Promise<string> {
const [row] = await db
.insert(schema.pipelineEvents)
.values({ occurredAt: new Date(), ...event })
.returning({ id: schema.pipelineEvents.id });
if (row == null) {
throw new Error('serverRecordPipelineEvent: insert returned no row');
}
return row.id;
}

View File

@@ -0,0 +1,2 @@
export * from './actions/index.server.js';
export * from './queries/index.server.js';

View File

@@ -0,0 +1 @@
export * from './list-pipeline-events-for-org.server.js';

View File

@@ -0,0 +1,16 @@
import { desc, eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/** An org's outreach event history, most recent first — for the org detail/timeline view. */
export function serverListPipelineEventsForOrg(
db: NpOutreachDatabase | NpOutreachTransaction,
{ orgId }: { orgId: string },
) {
return db
.select()
.from(schema.pipelineEvents)
.where(eq(schema.pipelineEvents.orgId, orgId))
.orderBy(desc(schema.pipelineEvents.occurredAt));
}