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,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'));
}