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:
17
packages/outreach-core/src/db/db.ts
Normal file
17
packages/outreach-core/src/db/db.ts
Normal 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>;
|
||||
2
packages/outreach-core/src/db/index.ts
Normal file
2
packages/outreach-core/src/db/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './db.js';
|
||||
export * from './schema.js';
|
||||
339
packages/outreach-core/src/db/schema.ts
Normal file
339
packages/outreach-core/src/db/schema.ts
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './expire-closed-grants.server.js';
|
||||
export * from './insert-grants.server.js';
|
||||
@@ -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()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
2
packages/outreach-core/src/grants/index.server.ts
Normal file
2
packages/outreach-core/src/grants/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-open-grants.server.js';
|
||||
@@ -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'));
|
||||
}
|
||||
6
packages/outreach-core/src/index.server.ts
Normal file
6
packages/outreach-core/src/index.server.ts
Normal 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';
|
||||
9
packages/outreach-core/src/index.ts
Normal file
9
packages/outreach-core/src/index.ts
Normal 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';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './insert-match.server.js';
|
||||
export * from './set-match-review.server.js';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal file
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
173
packages/outreach-core/src/matches/hard-gates.ts
Normal file
173
packages/outreach-core/src/matches/hard-gates.ts
Normal 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;
|
||||
}
|
||||
6
packages/outreach-core/src/matches/index.server.ts
Normal file
6
packages/outreach-core/src/matches/index.server.ts
Normal 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';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-pending-review-matches.server.js';
|
||||
@@ -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'));
|
||||
}
|
||||
1
packages/outreach-core/src/orgs/actions/index.server.ts
Normal file
1
packages/outreach-core/src/orgs/actions/index.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './insert-org.server.js';
|
||||
30
packages/outreach-core/src/orgs/actions/insert-org.server.ts
Normal file
30
packages/outreach-core/src/orgs/actions/insert-org.server.ts
Normal 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;
|
||||
}
|
||||
2
packages/outreach-core/src/orgs/index.server.ts
Normal file
2
packages/outreach-core/src/orgs/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
1
packages/outreach-core/src/orgs/queries/index.server.ts
Normal file
1
packages/outreach-core/src/orgs/queries/index.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './list-orgs-in-icp-band.server.js';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './record-pipeline-event.server.js';
|
||||
@@ -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;
|
||||
}
|
||||
2
packages/outreach-core/src/pipeline/index.server.ts
Normal file
2
packages/outreach-core/src/pipeline/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-pipeline-events-for-org.server.js';
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user