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 @@
# tsconfig

View File

@@ -0,0 +1,21 @@
{
"name": "@novelpad/config",
"packageManager": "yarn@4.5.0",
"dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.2.10",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/dm-serif-display": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/libre-baskerville": "^5.2.8"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.19",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.11"
}
}

710
packages/config/shadcn.css Normal file
View File

@@ -0,0 +1,710 @@
@import '@fontsource/inter';
@import '@fontsource-variable/bricolage-grotesque';
@import '@fontsource-variable/outfit';
@import '@fontsource-variable/source-serif-4';
@import '@fontsource-variable/lora';
@import '@fontsource/courier-prime';
@import '@fontsource/dm-serif-display';
/* Libre Baskerville — a user-selectable editor font (font-family-dropdown.tsx),
self-hosted so it keeps working offline after the Google-Fonts CDN removal. */
@import '@fontsource/libre-baskerville';
@font-face {
font-family: 'Fenwick';
src: url('/Fenwick-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
font-display: swap;
}
/* ── Dialog system surfaces (brand-agnostic; spec: docs/redesign/dialogs) ──
The dialog surface is intentionally NOT --background: it matches the elevated
floating-toolbar / popover color so modals read as the top layer. */
:root {
--np-dialog-surface: #ffffff;
--np-dialog-line: rgba(28, 26, 21, 0.1);
--np-scrim: rgba(28, 26, 21, 0.34);
--np-dialog-shadow: 0 32px 90px rgba(28, 26, 21, 0.24), 0 2px 8px rgba(28, 26, 21, 0.1);
/* Popover-tier elevation — lighter than the dialog shadow. Shared by
popover/dropdown/select content for a consistent floating-surface feel. */
--np-popover-shadow: 0 8px 24px rgba(28, 26, 21, 0.12), 0 2px 6px rgba(28, 26, 21, 0.08);
/* Brand accent tokens consumed by the dialog system (IconBadge gradient,
DialogNote/DialogField brand tone). Defaulted here so they always resolve
pre-hydration (before BrandingProvider sets data-brand) and for any
unbranded root; the per-brand blocks below override them. Default = NovelPad. */
--np-gradient: linear-gradient(135deg, #22D3EE 0%, #D946EF 55%, #EC4899 100%);
--np-gradient-soft: linear-gradient(135deg, rgba(34, 211, 238, .18) 0%, rgba(217, 70, 239, .18) 55%, rgba(236, 72, 153, .18) 100%);
--np-brand-ink: #D946EF;
--np-brand-soft: rgba(217, 70, 239, 0.13);
}
.dark {
--np-dialog-surface: #1b2238;
--np-dialog-line: rgba(255, 255, 255, 0.1);
--np-scrim: rgba(6, 9, 20, 0.64);
--np-dialog-shadow: 0 32px 90px rgba(0, 0, 0, 0.62), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
--np-popover-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--constructive: 142.1 70.6% 45.3%;
--constructive-foreground: 210 40% 98%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--highlight: 217 91% 60%;
--radius: 0.5rem;
/* Theme-aware radius scale. Values mirror the current Tailwind output so
introducing themes does not change today's NovelPad appearance. */
--radius-sm: 0.25rem;
--radius-xs: 0.1875rem;
--radius-base: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-compact: 0.5625rem;
--radius-icon: 0.625rem;
--radius-control: 0.6875rem;
--radius-tile: 0.75rem;
--radius-badge: 0.8125rem;
--radius-panel: 0.875rem;
--radius-dialog: 1.125rem;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-reading: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--app-header-background: hsl(var(--background));
--app-header-foreground: hsl(var(--foreground));
--app-header-border: hsl(var(--border));
--column: 210 40% 92.1%;
--column-foreground: 222.2 47.4% 11.2%;
--column-border: 210 40% 86.1%;
/* Board-specific surfaces default to today's muted canvas and floating
section stacks. Visual themes can override them independently without
changing muted UI throughout the rest of the application. */
--board-background: var(--muted);
--board-action-foreground: hsl(var(--foreground));
--board-column-gap: 1.5rem;
--board-padding-inline: 1.5rem;
--board-padding-block-start: 1.25rem;
--board-padding-block-end: 0rem;
--board-padding-block-end-stacked: 4rem;
--board-header-background: hsl(var(--background));
--board-header-foreground: hsl(var(--foreground));
--board-header-muted-background: hsl(var(--muted) / 0.5);
--board-header-muted-foreground: hsl(var(--muted-foreground));
--board-header-border: hsl(var(--column-border));
--board-header-border-width: 1px;
--board-header-control-border: hsl(var(--border));
--board-footer-background: hsl(var(--muted));
--board-footer-foreground: hsl(var(--muted-foreground));
--board-footer-muted-foreground: hsl(var(--muted-foreground) / 0.7);
--board-footer-hover-background: hsl(var(--accent));
--board-footer-border: hsl(var(--border));
--board-footer-border-width: 1px;
--section-column-background: transparent;
--section-column-radius: 0rem;
/* Recessed board canvas (ink-0): darker than --background (chrome). */
--canvas: 0 0% 100%;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 213 31% 91%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--constructive: 142.1 44.1% 37.1%;
--constructive-foreground: 210 40% 98%;
--destructive: 0 63% 42%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--highlight: 217 91% 60%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--column: 224 31% 22%;
--column-foreground: 213 31% 91%;
--column-border: 216 24% 30%;
--canvas: 224 71% 3%;
/* Raised card surface (lifts scene cards above the same-ink board in dark
mode). Brand blocks override this with their own on-brand value. */
--card-raised: 226 39% 15.5%;
}
}
:root[data-brand="helmdocs"] {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 24.6 95% 53.1%;
--highlight: 24.6 95% 53.1%;
--sidebar-background: 30 6% 97%;
--sidebar-foreground: 20 14.3% 4.1%;
--sidebar-primary: 24.6 95% 53.1%;
--sidebar-primary-foreground: 60 9.1% 97.8%;
--sidebar-accent: 60 4.8% 95.9%;
--sidebar-accent-foreground: 24 9.8% 10%;
--sidebar-border: 20 5.9% 90%;
--sidebar-ring: 24.6 95% 53.1%;
--column: 35 5% 92%;
--column-foreground: 20 14.3% 4.1%;
--column-border: 30 5% 85%;
--canvas: 0 0% 100%;
}
.dark[data-brand="helmdocs"] {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 20.5 90.2% 48.2%;
--highlight: 20.5 90.2% 48.2%;
--sidebar-background: 20 10% 7%;
--sidebar-foreground: 60 9.1% 97.8%;
--sidebar-primary: 20.5 90.2% 48.2%;
--sidebar-primary-foreground: 60 9.1% 97.8%;
--sidebar-accent: 12 6.5% 15.1%;
--sidebar-accent-foreground: 60 9.1% 97.8%;
--sidebar-border: 12 6.5% 15.1%;
--sidebar-ring: 20.5 90.2% 48.2%;
--column: 15 10% 14%;
--column-foreground: 60 9.1% 97.8%;
--column-border: 12 8% 22%;
--canvas: 20 14.3% 3%;
/* HelmDocs cards already lift: --card (L 4.1%) is darker than the muted board
(L 15.1%), so the raised surface stays at --card — no NovelPad ink-navy. */
--card-raised: 20 14.3% 4.1%;
/* Dialog surface: a neutral raised dark panel at the --secondary level, NOT
the NovelPad ink-navy (#1b2238 from the .dark default). Keeps modals lifted
off the near-black background while staying on-brand. */
--np-dialog-surface: hsl(12 6.5% 15.1%);
--np-scrim: rgba(0, 0, 0, 0.64);
}
/* HelmDocs brand fonts (Outfit — geometric sans for body + headings;
theme-agnostic). Outfit has no serif companion, so --font-serif (used by
headings, e.g. the auth card title) also points at Outfit. Reading + mono
stay on the system/Georgia stacks. Variable face registers as
"Outfit Variable" — lead the stack with that or the self-hosted face never
loads. */
:root[data-brand="helmdocs"],
.dark[data-brand="helmdocs"] {
--font-sans: "Outfit Variable", "Outfit", ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
--font-serif: "Outfit Variable", "Outfit", ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
--font-reading: Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--font-display: "Outfit Variable", "Outfit", ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
/* HelmDocs brand sweep + accent: the orange ramp (mirrors --loader-gradient
and --primary), replacing the NovelPad cyan→fuchsia gradient in shared
dialog chrome (IconBadge, DialogNote/Field brand tone, step dots, etc.). */
--np-gradient: linear-gradient(135deg, hsl(24.6 95% 53.1%) 0%, hsl(20.5 90.2% 48.2%) 100%);
--np-gradient-soft: linear-gradient(135deg, hsla(24.6, 95%, 53.1%, .18) 0%, hsla(20.5, 90.2%, 48.2%, .18) 100%);
--np-brand-ink: hsl(24.6 95% 53.1%);
--np-brand-soft: hsla(24.6, 95%, 53.1%, .14);
}
:root[data-brand="novelpad"] {
--background: 37.5 50% 96.9%;
--foreground: 42.9 14.3% 9.6%;
--muted: 40 38.5% 92.4%;
--muted-foreground: 32.7 6.9% 31.2%;
--popover: 37.5 50% 96.9%;
--popover-foreground: 42.9 14.3% 9.6%;
--border: 40 34.3% 86.3%;
--input: 40 34.3% 86.3%;
/* Card is the raised ink: lighter than the paper --background (L 96.9%) so it
lifts in light mode, mirroring dark mode where --card (L 12.9%) sits above
--background (L 10.6%). A darker card here reads as a recessed panel. */
--card: 38 60% 99%;
--card-foreground: 42.9 14.3% 9.6%;
--primary: 42.9 14.3% 9.6%;
--primary-foreground: 37.5 50% 96.9%;
--secondary: 40 38.5% 92.4%;
--secondary-foreground: 42.9 14.3% 9.6%;
--accent: 40 38.5% 92.4%;
--accent-foreground: 42.9 14.3% 9.6%;
--constructive: 160.1 84.1% 39.4%;
--constructive-foreground: 37.5 50% 96.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 37.5 50% 96.9%;
--ring: 188.7 94.5% 42.7%;
--highlight: 188.7 94.5% 42.7%;
--sidebar-background: 40 48.4% 93.9%;
--sidebar-foreground: 32.7 6.9% 31.2%;
--sidebar-primary: 188.7 94.5% 42.7%;
--sidebar-primary-foreground: 37.5 50% 96.9%;
--sidebar-accent: 40 38.5% 92.4%;
--sidebar-accent-foreground: 42.9 14.3% 9.6%;
--sidebar-border: 40 34.3% 86.3%;
--sidebar-ring: 188.7 94.5% 42.7%;
--column: 40 38.5% 92.4%;
--column-foreground: 42.9 14.3% 9.6%;
--column-border: 40 34.3% 86.3%;
/* Pro's status bar belongs to the board surface rather than reading as a
separate bordered panel. */
--board-footer-background: hsl(var(--board-background));
--board-footer-border: transparent;
--board-footer-border-width: 0px;
--canvas: 37.5 50% 96.9%;
}
.dark[data-brand="novelpad"] {
--background: 225 44.4% 10.6%;
--foreground: 216 33.3% 97.1%;
--muted: 226.2 39.4% 12.9%;
--muted-foreground: 221.5 17.6% 71%;
/* Menus/popovers sit at ink-3 (raised above the ink-2 cards), matching the
prototype's scene/stack/board menus. */
--popover: 224.1 36.2% 18.4%;
--popover-foreground: 216 33.3% 97.1%;
--border: 224.1 36.2% 18.4%;
--input: 223.9 32.8% 24.5%;
--card: 226.2 39.4% 12.9%;
--card-foreground: 216 33.3% 97.1%;
--primary: 216 33.3% 97.1%;
--primary-foreground: 225 44.4% 10.6%;
--secondary: 224.1 36.2% 18.4%;
--secondary-foreground: 216 33.3% 97.1%;
--accent: 224.1 36.2% 18.4%;
--accent-foreground: 216 33.3% 97.1%;
--constructive: 160.1 84.1% 39.4%;
--constructive-foreground: 216 33.3% 97.1%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 216 33.3% 97.1%;
--ring: 187.9 85.7% 53.3%;
--highlight: 187.9 85.7% 53.3%;
/* Sidebar = ink-1 (chrome level), matching the toolbar; the board canvas
(--canvas) is the darker ink-0 layer below it. */
--sidebar-background: 225 44.4% 10.6%;
--sidebar-foreground: 225 21.7% 82%;
--sidebar-primary: 187.9 85.7% 53.3%;
--sidebar-primary-foreground: 225 44.4% 10.6%;
--sidebar-accent: 224.1 36.2% 18.4%;
--sidebar-accent-foreground: 216 33.3% 97.1%;
--sidebar-border: 224.1 36.2% 18.4%;
--sidebar-ring: 187.9 85.7% 53.3%;
--column: 224.1 36.2% 18.4%;
--column-foreground: 216 33.3% 97.1%;
--column-border: 223.9 32.8% 24.5%;
/* Recessed board canvas = ink-0 (#0B1020), darker than --background (ink-1). */
--canvas: 225.7 48.8% 8.4%;
/* Raised card: ink-2 (--card, L 12.9%) lifted to L 15.5% so scene cards read
above the same-ink board (--muted). */
--card-raised: 226.2 39.4% 15.5%;
}
/* NovelPad brand fonts, gradient, tag ramp, motion + focus atmosphere (theme-agnostic) */
:root[data-brand="novelpad"],
.dark[data-brand="novelpad"] {
--font-sans: "Bricolage Grotesque Variable", "Bricolage Grotesque", ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
--font-reading: "Lora Variable", "Lora", "Iowan Old Style", Georgia, serif;
--font-mono: "Courier Prime", "Courier New", ui-monospace, "SF Mono", Menlo, monospace;
--font-display: "Fenwick", "DM Serif Display", "Source Serif 4 Variable", "Source Serif 4", Georgia, serif;
--np-gradient: linear-gradient(135deg, #22D3EE 0%, #D946EF 55%, #EC4899 100%);
--np-gradient-soft: linear-gradient(135deg, rgba(34, 211, 238, .18) 0%, rgba(217, 70, 239, .18) 55%, rgba(236, 72, 153, .18) 100%);
--tag-cyan: #22D3EE;
--tag-pink: #EC4899;
--tag-plum: #7C3AED;
--tag-amber: #F59E0B;
--tag-sage: #5B8F6B;
--tag-dusk: #64748B;
--tag-coral: #FB7185;
--ease: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 150ms;
--dur-base: 220ms;
--dur-slow: 320ms;
--np-focus-ring: 0 0 0 2px hsl(var(--background)), 0 0 0 4px #22D3EE;
}
/* ── NovelPad Classic ──────────────────────────────────────────────────────
Visual theme for writers who prefer the previous NovelPad application.
Source palette: ../novelpad/src/novelpad/ux/theme.ts. The app's structural
layout stays current; semantic surfaces, accents, typography, and standard
radii reproduce the Classic character in both color modes. */
:root[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
--background: 60 11.1% 94.7%; /* #F3F3F0 */
--foreground: 247.1 11.4% 29.2%; /* #444253 */
--muted: 0 0% 93.3%; /* #EEEEEE */
--muted-foreground: 240 4% 34.7%; /* #55555C */
--popover: 0 0% 100%;
--popover-foreground: 247.1 11.4% 29.2%;
--border: 236.7 16.7% 78.8%; /* #C0C1D2 */
--input: 236.7 16.7% 78.8%;
--card: 0 0% 100%;
--card-foreground: 247.1 11.4% 29.2%;
--card-raised: 0 0% 100%;
--primary: 198.7 73.2% 49.8%; /* #22A2DC */
--primary-foreground: 253.3 20.9% 8.4%;
--secondary: 334.1 74.7% 52%; /* #E02978 */
--secondary-foreground: 0 0% 100%;
--accent: 236.7 16.7% 78.8%;
--accent-foreground: 244.6 11.5% 22.2%;
--constructive: 128.2 44.2% 44.3%; /* #3FA34D */
--constructive-foreground: 0 0% 100%;
--destructive: 6.2 81.7% 62.7%; /* #EE6352 */
--destructive-foreground: 253.3 20.9% 8.4%;
--ring: 198.7 73.2% 49.8%;
--highlight: 198.7 73.2% 49.8%;
/* Classic's navigation rail was dark in both modes. */
--sidebar-background: 244.6 11.5% 22.2%;
--sidebar-foreground: 210 21.4% 94.5%;
--sidebar-primary: 198.7 73.2% 49.8%;
--sidebar-primary-foreground: 253.3 20.9% 8.4%;
--sidebar-accent: 240 11.7% 15.1%;
--sidebar-accent-foreground: 210 21.4% 94.5%;
--sidebar-border: 244.6 11.5% 22.2%;
--sidebar-ring: 198.7 73.2% 49.8%;
--app-header-background: hsl(var(--sidebar-background));
--app-header-foreground: hsl(var(--sidebar-foreground));
--app-header-border: hsl(var(--sidebar-border));
--column: 0 0% 93.3%;
--column-foreground: 240 8.1% 21.8%;
--column-border: 236.7 16.7% 78.8%;
--board-background: 240 11.7% 15.1%; /* #22222B */
--board-action-foreground: #EEF1F4;
--canvas: 60 11.1% 94.7%;
--np-dialog-surface: #FFFFFF;
--np-dialog-line: rgba(68, 66, 83, 0.18);
--np-scrim: rgba(19, 17, 26, 0.38);
--np-dialog-shadow: 0 18px 48px rgba(34, 34, 43, 0.22), 0 2px 6px rgba(34, 34, 43, 0.10);
--np-popover-shadow: 0 8px 20px rgba(34, 34, 43, 0.18);
}
.dark[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
--background: 253.3 20.9% 8.4%; /* #13111A */
--foreground: 210 21.4% 94.5%; /* #EEF1F4 */
--muted: 244.6 11.5% 22.2%; /* #33323F */
--muted-foreground: 236.7 16.7% 78.8%;
--popover: 244.6 11.5% 22.2%;
--popover-foreground: 210 21.4% 94.5%;
--border: 247.1 11.4% 29.2%; /* #444253 */
--input: 240 4% 34.7%; /* #55555C */
--card: 240 11.7% 15.1%; /* #22222B */
--card-foreground: 210 21.4% 94.5%;
--card-raised: 240 11.7% 15.1%;
--primary: 198.7 73.2% 49.8%;
--primary-foreground: 253.3 20.9% 8.4%;
--secondary: 334.1 74.7% 52%;
--secondary-foreground: 0 0% 100%;
--accent: 244.6 11.5% 22.2%;
--accent-foreground: 210 21.4% 94.5%;
--constructive: 128.2 44.2% 44.3%;
--constructive-foreground: 253.3 20.9% 8.4%;
--destructive: 6.2 81.7% 62.7%;
--destructive-foreground: 253.3 20.9% 8.4%;
--ring: 198.7 73.2% 49.8%;
--highlight: 198.7 73.2% 49.8%;
--sidebar-background: 244.6 11.5% 22.2%;
--sidebar-foreground: 210 21.4% 94.5%;
--sidebar-primary: 198.7 73.2% 49.8%;
--sidebar-primary-foreground: 253.3 20.9% 8.4%;
--sidebar-accent: 240 11.7% 15.1%;
--sidebar-accent-foreground: 210 21.4% 94.5%;
--sidebar-border: 240 11.7% 15.1%;
--sidebar-ring: 198.7 73.2% 49.8%;
--app-header-background: hsl(var(--sidebar-background));
--app-header-foreground: hsl(var(--sidebar-foreground));
--app-header-border: hsl(var(--sidebar-border));
--column: 240 8.1% 21.8%; /* #33333C */
--column-foreground: 0 0% 93.3%;
--column-border: 240 4% 34.7%;
--board-background: 240 11.7% 15.1%; /* #22222B */
--board-action-foreground: #EEF1F4;
--canvas: 253.3 20.9% 8.4%;
--np-dialog-surface: #22222B;
--np-dialog-line: rgba(192, 193, 210, 0.18);
--np-scrim: rgba(19, 17, 26, 0.68);
--np-dialog-shadow: 0 24px 64px rgba(0, 0, 0, 0.56), inset 0 0 0 1px rgba(238, 241, 244, 0.025);
--np-popover-shadow: 0 10px 26px rgba(0, 0, 0, 0.44);
}
:root[data-brand="novelpad"][data-novel-theme="novelpad-classic"],
.dark[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
/* Outfit is already self-hosted and is the closest available geometric sans
to Classic's Montserrat without introducing a network-loaded font. */
--font-sans: "Outfit Variable", "Outfit", "Montserrat", "Roboto", "Helvetica", "Arial", sans-serif;
--font-serif: "Outfit Variable", "Outfit", "Montserrat", "Roboto", "Helvetica", "Arial", sans-serif;
--font-reading: "Libre Baskerville", Georgia, serif;
--font-display: "Outfit Variable", "Outfit", "Montserrat", "Roboto", "Helvetica", "Arial", sans-serif;
--np-gradient: linear-gradient(-45deg, #E02978 45%, #22A2DC 85%);
--np-gradient-soft: linear-gradient(-45deg, rgba(224, 41, 120, 0.18) 45%, rgba(34, 162, 220, 0.18) 85%);
--np-brand-ink: #22A2DC;
--np-brand-soft: rgba(34, 162, 220, 0.14);
--np-focus-ring: 0 0 0 2px hsl(var(--background)), 0 0 0 4px #22A2DC;
--tag-cyan: #08B2E3;
--tag-pink: #EE6352;
--tag-plum: #B38CB4;
--tag-amber: #E8AE68;
--tag-sage: #3FA34D;
--radius: 0.25rem;
--radius-sm: 0.25rem;
--radius-xs: 0.25rem;
--radius-base: 0.25rem;
--radius-md: 0.25rem;
--radius-lg: 0.25rem;
--radius-xl: 0.25rem;
--radius-2xl: 0.25rem;
--radius-compact: 0.25rem;
--radius-icon: 0.25rem;
--radius-control: 0.25rem;
--radius-tile: 0.25rem;
--radius-badge: 0.25rem;
--radius-panel: 0.25rem;
--radius-dialog: 0.25rem;
/* Classic used one seven-pixel grid unit between stacks and around the
board edges (../novelpad/src/novelpad/ux/theme.ts). */
--board-column-gap: 0.4375rem;
--board-padding-inline: 0.4375rem;
--board-padding-block-start: 0.4375rem;
--board-padding-block-end: 0.4375rem;
--board-padding-block-end-stacked: 0.4375rem;
--board-header-background: hsl(var(--board-background));
--board-header-foreground: #EEF1F4;
--board-header-muted-background: rgba(238, 241, 244, 0.08);
--board-header-muted-foreground: #C0C1D2;
--board-header-border: transparent;
--board-header-border-width: 0px;
--board-header-control-border: #444253;
--board-footer-background: hsl(var(--board-background));
--board-footer-foreground: #EEF1F4;
--board-footer-muted-foreground: #C0C1D2;
--board-footer-hover-background: rgba(238, 241, 244, 0.08);
--board-footer-border: transparent;
--board-footer-border-width: 0px;
--section-column-background: hsl(var(--column));
--section-column-radius: 0.25rem;
}
.btn-gradient {
background: var(--np-gradient);
color: #fff;
}
[data-slot="board-header"] [data-slot="board-type-chip"] {
background-color: var(--board-header-muted-background);
border-color: var(--board-header-control-border);
color: var(--board-header-muted-foreground);
}
@layer base {
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/*
* --mention-ink: the link color for @-mention chips (and the active typeahead
* highlight). On dark surfaces the bright brand --highlight reads well, but on
* light surfaces the high-luminance accents (cyan/blue/orange) wash out — so we
* deepen them per brand for legible contrast. Resolved against the originating
* element, so it also feeds `::highlight(mention-active)` (mark.css). Kept out
* of @layer base so these win over the layered theme blocks.
*/
:root {
--mention-ink: var(--highlight);
}
:root:not(.dark) {
--mention-ink: 217 78% 48%;
}
:root:not(.dark)[data-brand="helmdocs"] {
--mention-ink: 24.6 88% 44%;
}
:root:not(.dark)[data-brand="novelpad"] {
--mention-ink: 192 80% 30%;
}
:root:not(.dark)[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
--mention-ink: 198.7 83% 34%;
}
/*
* Loader + skeleton design tokens (see docs/features/loaders.md). These feed the
* brand loader system in packages/app/src/components/common/loaders/:
* --loader-gradient the brand sweep used by Spinner / Progress / skeleton stripe
* --np-skel-track resting "bone" fill of a skeleton block
* --np-skel-tint travelling highlight on the skeleton sweep
* --np-skel-line skeleton card hairline (defaults to the theme border)
* --np-skel-speed skeleton shimmer period (overridable inline per instance)
* The handoffs are dark-tuned, so the light values raise contrast. Kept out of
* @layer base so they win over the layered theme/brand blocks. NovelPad is the
* default brand; helmdocs overrides keep the system on-brand if a *shared*
* loader renders on a Helm surface (Helm skeletons themselves stay animate-pulse).
*/
:root {
--loader-gradient: linear-gradient(135deg, #22d3ee 0%, #d946ef 55%, #ec4899 100%);
--np-skel-track: rgba(20, 18, 15, 0.06);
--np-skel-tint: rgba(34, 211, 238, 0.18);
--np-skel-line: hsl(var(--border));
--np-skel-speed: 1400ms;
}
.dark {
--np-skel-track: rgba(255, 255, 255, 0.07);
--np-skel-tint: rgba(150, 228, 247, 0.17);
}
/*
* Full-screen loading surface + inks (LoadingScreen / desktop boot splash).
* Light = NovelPad paper (#FBF8F3 = the light --background) under a faint brand
* glow; dark = the deep ink navy from the loader handoff. The desktop splash
* (apps/desktop/src/renderer/src/splash.tsx) renders before this sheet is
* guaranteed, so it mirrors these values inline — keep the two in sync.
*/
:root {
--np-loading-surface: radial-gradient(120% 90% at 50% 42%, rgba(217, 70, 239, 0.10) 0%, rgba(34, 211, 238, 0.07) 32%, rgba(251, 248, 243, 0) 64%), #FBF8F3;
--np-loading-ink: #1C1A15;
--np-loading-muted: rgba(28, 26, 21, 0.60);
--np-loading-subtitle: rgba(28, 26, 21, 0.45);
--np-loading-line: rgba(28, 26, 21, 0.16);
}
.dark {
--np-loading-surface: radial-gradient(120% 90% at 50% 42%, rgba(217, 70, 239, 0.10) 0%, rgba(34, 211, 238, 0.06) 32%, rgba(15, 21, 39, 0) 64%), #0B0F1A;
--np-loading-ink: #F5F7FA;
--np-loading-muted: rgba(245, 247, 250, 0.55);
--np-loading-subtitle: rgba(245, 247, 250, 0.40);
--np-loading-line: rgba(245, 247, 250, 0.16);
}
/* HelmDocs: plain theme surfaces, no NovelPad glow. */
:root[data-brand="helmdocs"] {
--np-loading-surface: hsl(0 0% 100%);
--np-loading-ink: hsl(20 14.3% 4.1%);
}
.dark[data-brand="helmdocs"] {
--np-loading-surface: hsl(20 14.3% 4.1%);
--np-loading-ink: hsl(60 9.1% 97.8%);
}
:root[data-brand="helmdocs"] {
--loader-gradient: linear-gradient(135deg, hsl(24.6 95% 53.1%) 0%, hsl(20.5 90.2% 48.2%) 100%);
--np-skel-tint: rgba(251, 146, 60, 0.16);
/* Spinner (loader #4) conic sweep — retinted to the HelmDocs orange ramp so
the shared Spinner / loaderToast reads on-brand instead of leaking the
NovelPad cyan→plum→pink sweep. The stops map to spinner.css's conic slots
(transparent → light 180deg → mid 280deg → deep 360deg). */
--np-spinner-from: rgba(251, 146, 60, 0);
--np-spinner-cyan: #fb923c;
--np-spinner-plum: #f97316;
--np-spinner-pink: #ea580c;
}
.dark[data-brand="helmdocs"] {
--np-skel-tint: rgba(251, 146, 60, 0.14);
--np-spinner-from: rgba(251, 146, 60, 0);
--np-spinner-cyan: #fb923c;
--np-spinner-plum: #f97316;
--np-spinner-pink: #ea580c;
}
:root[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
--loader-gradient: linear-gradient(-45deg, #E02978 45%, #22A2DC 85%);
--np-skel-track: rgba(68, 66, 83, 0.09);
--np-skel-tint: rgba(34, 162, 220, 0.18);
--np-loading-surface: #F3F3F0;
--np-loading-ink: #444253;
--np-loading-muted: rgba(68, 66, 83, 0.66);
--np-loading-subtitle: rgba(68, 66, 83, 0.48);
--np-loading-line: rgba(68, 66, 83, 0.20);
}
.dark[data-brand="novelpad"][data-novel-theme="novelpad-classic"] {
--np-skel-track: rgba(238, 241, 244, 0.08);
--np-skel-tint: rgba(34, 162, 220, 0.18);
--np-loading-surface: #13111A;
--np-loading-ink: #EEF1F4;
--np-loading-muted: rgba(238, 241, 244, 0.60);
--np-loading-subtitle: rgba(238, 241, 244, 0.44);
--np-loading-line: rgba(238, 241, 244, 0.18);
}

View File

@@ -0,0 +1,125 @@
import { type Config } from 'tailwindcss';
export default {
darkMode: 'class',
content: [
'../core/src/**/*.{js,ts,jsx,tsx}',
'../app/src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)'],
serif: ['var(--font-serif)'],
reading: ['var(--font-reading)'],
mono: ['var(--font-mono)'],
display: ['var(--font-display)'],
},
zIndex: {
base: '100',
modal: '200',
overlay: '300',
popover: '400',
tooltip: '500',
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
// Brand accent for highlighting/active states (NovelPad cyan, HelmDocs
// orange). The alpha-value placeholder lets utilities tint it, e.g.
// `text-highlight`, `bg-highlight/15`, `border-highlight/40`.
highlight: 'hsl(var(--highlight) / <alpha-value>)',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
brand: {
DEFAULT: '#22D3EE',
blue: '#22D3EE',
purple: '#D946EF',
pink: '#EC4899',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
constructive: {
DEFAULT: 'hsl(var(--constructive))',
foreground: 'hsl(var(--constructive-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
column: {
DEFAULT: 'hsl(var(--column))',
foreground: 'hsl(var(--column-foreground))',
border: 'hsl(var(--column-border))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
sm: 'var(--radius-sm)',
DEFAULT: 'var(--radius-base)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
'2xl': 'var(--radius-2xl)',
},
aspectRatio: {
'3/2': '3 / 2',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography'), require('@tailwindcss/container-queries')],
} satisfies Config;

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"module": "NodeNext",
"jsx": "react-jsx",
"sourceMap": true,
"incremental": true
}
}

View File

@@ -0,0 +1,32 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import url from 'node:url';
const WRONG_CODE = `import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";`;
export function reactVirtualized() {
return {
name: 'flat:react-virtualized',
// Note: we cannot use the `transform` hook here
// because libraries are pre-bundled in vite directly,
// plugins aren't able to hack that step currently.
// so instead we manually edit the file in node_modules.
// all we need is to find the timing before pre-bundling.
configResolved: async () => {
const require = createRequire(import.meta.url);
const reactVirtualizedPath = require.resolve('react-virtualized');
const { pathname: reactVirtualizedFilePath } = new url.URL(
reactVirtualizedPath,
import.meta.url,
);
const file = reactVirtualizedFilePath.replace(
path.join('dist', 'commonjs', 'index.js'),
path.join('dist', 'es', 'WindowScroller', 'utils', 'onScroll.js'),
);
const code = await fs.readFile(file, 'utf-8');
const modified = code.replace(WRONG_CODE, '');
await fs.writeFile(file, modified);
},
};
}

View File

@@ -0,0 +1,54 @@
# @novelpad/outreach-ai
LLM access layer for the HelmDocs grant-match outreach engine. Modeled on
`novelpad-desktop`'s `packages/ai`, trimmed to what this engine needs: a
Vertex/Gemini client, an embeddings helper, model-tier constants, and two
structured-output agents (org profiler, mission-fit judge).
## Provenance
`src/gemini.ts` and `src/embeddings.ts` are copied near-verbatim from
`novelpad-desktop` at commit `62c56b87` (see the header comment in each file
for the exact source path). They are kept as close to the original as
compiles standalone in this workspace — do not "clean up" drift between them
and the source without checking whether the source has moved on too.
`src/agents/*/run.ts` follow the structured-JSON-output pattern from
`novelpad-desktop`'s `packages/ai/src/agents/grant/section-drafter/run.ts`
(prompt builder + `generateContent` with `responseMimeType: 'application/json'`
and a hand-written `responseSchema`), but are new code for this repo's domain,
not copies — both are currently `NOT IMPLEMENTED` stubs pending the
source-fetching / hard-gate scoring infrastructure they depend on. Each stub's
doc comment shows the intended call shape.
## Never mix embedding models
`generateQueryEmbedding` / `generateDocumentEmbedding` / `generateDocumentEmbeddings`
are pinned to `gemini-embedding-001` at `outputDimensionality: 1536`. **Do not
change either value**, and do not add a second embedding model/dimensionality
into this package. Every vector this package produces has to remain
comparable (same model, same dimensionality) against every other vector
already stored in the HelmDocs RAG index — switching models or dimensions
silently corrupts similarity search for anything embedded before the switch,
with no error at write time. If a better embedding model becomes available,
that's a deliberate, full-reindex migration, not a constant change here.
## Model tiering
See `src/models.ts`: `BULK_MODEL` (`gemini-2.5-flash`) for high-volume
mechanical work (classification, effort estimates, first-pass org-profile
extraction); `JUDGE_MODEL` (`gemini-2.5-pro`) for anything a wrong answer
could put in front of a real prospect (mission-fit judge, email
personalization QA).
## Layout
- `src/gemini.ts``getAi()` Vertex client singleton + `CHAT_MODEL`.
- `src/embeddings.ts` — query/document embedding helpers.
- `src/models.ts``BULK_MODEL` / `JUDGE_MODEL` constants.
- `src/agents/org-profiler/``OrgProfileSchema` (per-field source URL +
confidence) and the profiler agent stub.
- `src/agents/mission-fit-judge/``MissionFitVerdictSchema` (`fit`,
`citedOrgProgram`, `citedGrantPriority`, `reasoning`) and the judge agent
stub.
- `src/index.ts` — barrel export.

View File

@@ -0,0 +1,32 @@
{
"name": "@novelpad/outreach-ai",
"version": "1.0.0",
"packageManager": "yarn@4.5.0",
"type": "module",
"sideEffects": false,
"imports": {
"#~/*": "./dist/*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@google/genai": "^1.41.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@novelpad/config": "workspace:^",
"@types/node": "^22",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,64 @@
import { MissionFitVerdictSchema, type MissionFitVerdict } from './schema.js';
import type { OrgProfile } from '../org-profiler/schema.js';
export interface RunMissionFitJudgeInput {
/** Extracted profile of the candidate org (from the Org Profiler). */
orgProfile: OrgProfile;
/** Grant program name / title, as scored against by the deterministic SQL gates. */
grantProgramName: string;
/** Funding priorities / eligible-use language pulled from the grant's own source text. */
grantPriorities: string[];
}
/**
* Build the judge prompt for one (org, grant) match candidate. Kept separate
* from `runMissionFitJudge` so it's independently unit-testable once wired up.
*/
export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string {
return [
'You are the final mission-fit judge for a candidate (org, grant) match',
'that has already passed deterministic SQL hard gates (eligibility,',
'geography, award range). Decide whether the org\'s actual programs',
'plausibly fit the grant\'s funding priorities. You MUST cite one',
'concrete org program and one concrete grant priority your verdict is',
'grounded in — a verdict without both citations is invalid. A wrong',
'"fit: true" here can put a real NH nonprofit in front of a funder that',
'will never fund them, so when the fit is unclear, prefer `fit: false`.',
'',
`Grant program: ${input.grantProgramName}`,
`Grant priorities: ${input.grantPriorities.join('; ')}`,
`Org legal name: ${input.orgProfile.legalName.value}`,
`Org mission: ${input.orgProfile.mission.value}`,
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`,
].join('\n');
}
/**
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a
* human reviewer (and, downstream, a real prospect via Apollo), so it should
* not go live against real matches until the deterministic hard-gate scoring
* this package doesn't own is wired in ahead of it. Intended production call
* shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`,
* not `BULK_MODEL`: a wrong verdict here reaches a prospect:
*
* import { getAi } from '../../gemini.js';
* import { JUDGE_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: JUDGE_MODEL,
* contents: buildMissionFitJudgePrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema
* // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return MissionFitVerdictSchema.parse(raw);
*/
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> {
throw new Error('not implemented');
}

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
/**
* Mission-fit judge verdict for one (org, grant) match candidate. This is
* the last human-facing gate before a match is surfaced for review — the
* judge must ground its verdict in something concrete from each side rather
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not
* optional summary fields.
*/
export const MissionFitVerdictSchema = z.object({
/** Whether the org's mission plausibly fits the grant's funding priorities. */
fit: z.boolean(),
/** The specific org program/activity the verdict is grounded in (from the org profile). */
citedOrgProgram: z.string().min(1),
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */
citedGrantPriority: z.string().min(1),
/** Short human-readable justification tying the two citations together. */
reasoning: z.string().min(1),
});
export type MissionFitVerdict = z.infer<typeof MissionFitVerdictSchema>;

View File

@@ -0,0 +1,52 @@
import { OrgProfileSchema, type OrgProfile } from './schema.js';
export interface RunOrgProfilerInput {
/** NH nonprofit legal or DBA name, as known so far (e.g. from a 990-PF index). */
orgName: string;
/** Candidate source URLs to ground extraction in — org website, 990-PF PDF, GuideStar/Charity Navigator page. */
sourceUrls: string[];
}
/**
* Build the producer prompt for the Org Profiler. Kept separate from
* `runOrgProfiler` so it's independently unit-testable once wired up.
*/
export function buildOrgProfilerPrompt(input: RunOrgProfilerInput): string {
return [
'You are extracting a structured profile for an NH nonprofit organization',
'from the source documents below. Every field must cite the exact source',
'URL it was read from and a 0-1 confidence. Do not fabricate a value —',
'omit or null a field you cannot ground in a source.',
'',
`Organization (working name): ${input.orgName}`,
`Sources: ${input.sourceUrls.join(', ')}`,
].join('\n');
}
/**
* NOT IMPLEMENTED — the profiler needs a source-fetching harness (web fetch /
* PDF-parse for 990-PFs, per the ingestion pipeline this package doesn't own)
* wired in before it can safely call Vertex with real source text. Intended
* production call shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output):
*
* import { getAi } from '../../gemini.js';
* import { BULK_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: BULK_MODEL,
* contents: buildOrgProfilerPrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: ORG_PROFILE_RESPONSE_SCHEMA — a Type/Schema literal
* // from '@google/genai' hand-mirroring OrgProfileSchema below.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return OrgProfileSchema.parse(raw);
*/
export async function runOrgProfiler(_input: RunOrgProfilerInput): Promise<OrgProfile> {
throw new Error('not implemented');
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { OrgProfileSchema } from './schema.js';
const validProfile = {
legalName: { value: 'NH Literacy Alliance', sourceUrl: 'https://nhliteracy.org', confidence: 0.95 },
ein: { value: '02-1234567', sourceUrl: 'https://apps.irs.gov/pfft', confidence: 0.9 },
mission: { value: 'Improve literacy outcomes for NH children.', sourceUrl: 'https://nhliteracy.org/about', confidence: 0.85 },
programAreas: {
value: ['youth literacy tutoring', 'family reading nights'],
sourceUrl: 'https://nhliteracy.org/programs',
confidence: 0.75,
},
geographicScope: { value: 'Hillsborough County, NH', sourceUrl: 'https://nhliteracy.org/about', confidence: 0.6 },
annualRevenue: { value: 480_000, sourceUrl: 'https://apps.irs.gov/pfft', confidence: 0.8 },
targetPopulations: {
value: ['K-5 students', 'low-income families'],
sourceUrl: 'https://nhliteracy.org/about',
confidence: 0.55,
},
};
describe('OrgProfileSchema', () => {
it('accepts a fully-sourced valid profile fixture', () => {
const result = OrgProfileSchema.safeParse(validProfile);
expect(result.success).toBe(true);
});
it('accepts a null ein and a null annualRevenue (org not matched to a 990-PF filing)', () => {
const result = OrgProfileSchema.safeParse({ ...validProfile, ein: null, annualRevenue: null });
expect(result.success).toBe(true);
});
it('rejects an ein that does not match the ##-####### federal EIN shape', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
ein: { value: 'not-an-ein', sourceUrl: null, confidence: 0.9 },
});
expect(result.success).toBe(false);
});
it('rejects a confidence outside the 0-1 range', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
mission: { value: 'Improve literacy outcomes.', sourceUrl: null, confidence: 1.4 },
});
expect(result.success).toBe(false);
});
it('rejects an empty programAreas array (sourcedField requires at least one)', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
programAreas: { value: [], sourceUrl: null, confidence: 0.5 },
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* A single extracted org-profile field, carrying its own provenance: the URL
* the value was pulled from (a 990-PF filing, the org's website, an NH
* Secretary of State record) and the model's confidence in the extraction.
* `sourceUrl: null` means the field was inferred rather than read verbatim
* from a single cited page (e.g. synthesized across multiple sources) —
* callers should treat a null-sourced field as lower-trust regardless of the
* reported confidence.
*/
function sourcedField<Value extends z.ZodTypeAny>(valueSchema: Value) {
return z.object({
value: valueSchema,
sourceUrl: z.string().url().nullable(),
confidence: z.number().min(0).max(1),
});
}
export const OrgProfileSchema = z.object({
/** EIN-qualified legal name, as it appears on the org's IRS filings. */
legalName: sourcedField(z.string().min(1)),
/** `##-#######` federal EIN. Null when the org couldn't be matched to a filing. */
ein: sourcedField(z.string().regex(/^\d{2}-?\d{7}$/)).nullable(),
/** Verbatim or lightly-condensed mission statement. */
mission: sourcedField(z.string().min(1)),
/** Free-text program-area tags (e.g. "youth mentoring", "food security"). */
programAreas: sourcedField(z.array(z.string().min(1)).min(1)),
/** Municipality/county/region the org primarily serves within NH. */
geographicScope: sourcedField(z.string().min(1)),
/** Most recent total-revenue figure, in whole dollars, from a 990-PF. */
annualRevenue: sourcedField(z.number().nonnegative()).nullable(),
/** Populations named as beneficiaries in the org's own materials. */
targetPopulations: sourcedField(z.array(z.string().min(1))),
});
export type OrgProfile = z.infer<typeof OrgProfileSchema>;

View File

@@ -0,0 +1,61 @@
// Copied/adapted from novelpad-desktop packages/ai/src/embeddings.ts @ 62c56b87
// Kept verbatim. NEVER change EMBEDDING_MODEL/EMBEDDING_DIMENSIONS below —
// `gemini-embedding-001` @ 1536 must stay identical for HelmDocs RAG vector compat.
import { getAi } from './gemini.js';
const EMBEDDING_MODEL = 'gemini-embedding-001';
const EMBEDDING_DIMENSIONS = 1536;
const MAX_BATCH_SIZE = 100;
/**
* Generate an embedding for a single text, using RETRIEVAL_QUERY task type
* (optimized for search queries).
*/
export async function generateQueryEmbedding(
text: string,
): Promise<number[]> {
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: text,
config: { taskType: 'RETRIEVAL_QUERY', outputDimensionality: EMBEDDING_DIMENSIONS },
});
return result.embeddings![0].values!;
}
/**
* Generate an embedding for a single text, using RETRIEVAL_DOCUMENT task type
* (optimized for document indexing).
*/
export async function generateDocumentEmbedding(
text: string,
): Promise<number[]> {
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: text,
config: { taskType: 'RETRIEVAL_DOCUMENT', outputDimensionality: EMBEDDING_DIMENSIONS },
});
return result.embeddings![0].values!;
}
/**
* Generate embeddings for multiple texts in batches.
* Uses RETRIEVAL_DOCUMENT task type. Batches requests to stay within API limits.
*/
export async function generateDocumentEmbeddings(
texts: string[],
): Promise<number[][]> {
const allEmbeddings: number[][] = [];
for (let i = 0; i < texts.length; i += MAX_BATCH_SIZE) {
const batch = texts.slice(i, i + MAX_BATCH_SIZE);
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: batch,
config: { taskType: 'RETRIEVAL_DOCUMENT', outputDimensionality: EMBEDDING_DIMENSIONS },
});
allEmbeddings.push(...result.embeddings!.map((e) => e.values!));
}
return allEmbeddings;
}

View File

@@ -0,0 +1,67 @@
// Copied/adapted from novelpad-desktop packages/ai/src/gemini.ts @ 62c56b87
// Kept verbatim, GCP service-account key resolution logic unchanged.
import { GoogleGenAI } from '@google/genai';
import fs from 'fs';
import path from 'path';
function findMonorepoRoot(from: string): string {
let dir = from;
while (dir !== path.dirname(dir)) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
if (pkg.workspaces) return dir;
} catch {}
dir = path.dirname(dir);
}
return from;
}
function resolveKeyFile() {
const keyFilePath = process.env.GCP_SERVICE_ACCOUNT_KEY_PATH;
if (!keyFilePath) return undefined;
if (path.isAbsolute(keyFilePath)) {
return JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
}
// Try resolving relative to cwd first
const fromCwd = path.resolve(process.cwd(), keyFilePath);
if (fs.existsSync(fromCwd)) {
return JSON.parse(fs.readFileSync(fromCwd, 'utf8'));
}
// Fall back to resolving relative to monorepo root
const root = findMonorepoRoot(process.cwd());
const fromRoot = path.resolve(root, keyFilePath);
if (fs.existsSync(fromRoot)) {
return JSON.parse(fs.readFileSync(fromRoot, 'utf8'));
}
// Last resort: try normalizing away excess ../ and resolve from root
const normalized = path.normalize(keyFilePath);
const basename = normalized.split(path.sep).filter(s => s !== '..').join(path.sep);
const fromRootNormalized = path.resolve(root, basename);
return JSON.parse(fs.readFileSync(fromRootNormalized, 'utf8'));
}
let _ai: GoogleGenAI | null = null;
export function getAi(): GoogleGenAI {
if (_ai == null) {
const credentials = resolveKeyFile();
_ai = new GoogleGenAI({
vertexai: true,
project: process.env.GCP_PROJECT_ID,
location: process.env.GCP_LOCATION ?? 'global',
googleAuthOptions: credentials ? { credentials } : undefined,
});
}
return _ai;
}
/**
* Default Vertex model for agent calls. Env-overridable via `CHAT_MODEL` so a
* working model (from `yarn workspace @novelpad/ai probe:vertex <model>`) can be
* set per environment without a code change. Defaults to the GA `gemini-2.5-pro`
* — NOT a `-preview` name, which can be disabled in a given GCP project and then
* fails silently (404 the SDK swallows to empty output / 0 tokens).
* Per-call override: `invokeVertex({ model })`.
*/
export const CHAT_MODEL = process.env.CHAT_MODEL ?? 'gemini-2.5-pro';

View File

@@ -0,0 +1,7 @@
export * from './gemini.js';
export * from './embeddings.js';
export * from './models.js';
export * from './agents/org-profiler/schema.js';
export * from './agents/org-profiler/run.js';
export * from './agents/mission-fit-judge/schema.js';
export * from './agents/mission-fit-judge/run.js';

View File

@@ -0,0 +1,18 @@
/**
* Model-tier constants for @novelpad/outreach-ai agent calls.
*
* Tiering rule: reach for `JUDGE_MODEL` — the frontier tier — wherever a
* wrong answer can reach a prospect. That means any call gating or shaping
* something a real NH nonprofit contact will read or that gates a match into
* outreach: the mission-fit judge's verdict (a wrong "fit: true" sends an
* off-mission email to a real org) and personalization QA on outbound Apollo
* sequence copy.
*
* Use `BULK_MODEL` for everything upstream and internal, where a wrong
* answer is still caught by a deterministic gate or a human reviewer before
* anything reaches a prospect: classification, effort estimates, and
* first-pass org-profile extraction (the profiler's output is reviewed by a
* human before a match is ever scored).
*/
export const BULK_MODEL = 'gemini-2.5-flash';
export const JUDGE_MODEL = 'gemini-2.5-pro';

View File

@@ -0,0 +1,21 @@
{
"extends": "@novelpad/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": false,
"paths": {
"#~/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,54 @@
# @novelpad/outreach-core
Domain/data package for the HelmDocs grant-match outreach engine: the Drizzle
schema for grants, orgs, org profiles, contacts, matches, and pipeline
events, plus the actions/queries that read and write them.
Modeled on `packages/core` in [novelpad-desktop](https://gitea.stephenmann.io/NovelPad/novelpad-desktop),
following the same [action/query pattern](https://gitea.stephenmann.io/NovelPad/novelpad-desktop/src/branch/master/docs/features/database-action-query-pattern.md):
every read lives in a domain's `queries/`, every write in its `actions/`,
both barrel-exported, and route handlers/workflows/hooks never touch
`db.select()`/`db.insert()` directly.
## Domains
- `grants/` — open grant opportunities (Grants.gov, NH state, 990-PF, PND RSS, Candid). Ingestion upserts on `sourceUrl`; an hourly sweep expires anything past its close date.
- `orgs/` — NH nonprofits, plus `org_profiles` (LLM/agent-researched enrichment: mission, programs, known funders, budget band, citation sources).
- `matches/` — a scored `(org, grant)` pair. `hard-gates.ts` is the deterministic, DB-free pass/fail predicate (entity eligibility, geography, ≥21-day runway, ≥$10k ceiling, application-form support) that runs *before* the weighted LLM subscoring pass — see its module doc for why it's kept pure.
- `pipeline/` — outreach lifecycle events (`enrolled``sent``opened`/`replied`/... → `demo_booked``converted`), synced from Apollo and recorded internally.
## Deliberate deviations from `@novelpad/core`
This package is **server-only** — there is no client app, so several things
`@novelpad/core` does for its PGlite/OPFS client don't apply here:
- **No PGlite, no client schema, no sync.** There's exactly one database
handle type (`NpOutreachDatabase`/`NpOutreachTransaction` in `src/db/db.ts`),
not the client/server pair (`NpClientDatabase` vs `NpDatabase`) `@novelpad/core`
needs to support both a local and a remote copy of the same data.
- **No `novelpad_core_dev` export condition.** `@novelpad/core`'s `package.json`
uses a custom `novelpad_core_dev` condition so dev builds resolve straight
to `src/*.ts` without a build step. This package's `exports` map only has
plain `types`/`default` conditions — run `yarn build` (or `yarn dev` for a
watch build) before consuming it from a sibling workspace in dev.
- **No `build:migration-files` bundling step.** `@novelpad/core`'s
`create:migration` also runs a script that bundles generated SQL into a
TS module the client can import for its OPFS-based local migrator. There
is no local migrator here, so `bin/create-migration` only runs
`drizzle-kit generate` against the single server config.
- **Root `index.ts` is intentionally small.** It exports only the schema,
DB types, and the pure `hard-gates` predicate — not domain client barrels,
since every domain here is server-only. Everything else lives behind
`index.server.ts` / the `./server` export condition.
## Scripts
```
yarn build # tsc
yarn dev # tsc --watch
yarn typecheck # tsc --noEmit
yarn test # vitest run
yarn create:migration <name> # drizzle-kit generate against drizzle.config.ts
yarn migrate # drizzle-kit migrate
yarn studio # drizzle-kit studio
```

View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Copied/adapted from novelpad-desktop packages/core/bin/create-migration @ 62c56b87
# Deviation: no client schema in this package, so only the single (server)
# drizzle config is generated against, and there is no separate
# build:migration-files bundling step to run afterward.
if [ "$#" -ne 1 ]; then
echo "Usage: create-migration <migration-name>"
echo "Or: yarn create:migration <migration-name>"
echo "Or: npm run create:migration -- <migration-name>"
exit 9
fi
echo "Creating migration: ${1}"
yarn build
yarn drizzle-kit generate --config drizzle.config.ts --name "${1}"

View File

@@ -0,0 +1,14 @@
// Copied/adapted from novelpad-desktop packages/core/drizzle.config.ts @ 62c56b87
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle/server',
dialect: 'postgresql',
migrations: {
prefix: 'unix',
},
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -0,0 +1,134 @@
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
CREATE TYPE "public"."application_effort_estimate" AS ENUM('loi_only', 'short_form', 'full_federal', 'unknown');--> statement-breakpoint
CREATE TYPE "public"."contact_priority" AS ENUM('named', 'generic');--> statement-breakpoint
CREATE TYPE "public"."contact_source_provider" AS ENUM('apollo', 'irs_990', 'website', 'manual');--> statement-breakpoint
CREATE TYPE "public"."email_status" AS ENUM('unverified', 'valid', 'risky', 'invalid');--> statement-breakpoint
CREATE TYPE "public"."grant_source" AS ENUM('grants_gov', 'nh_state', 'irs_990pf', 'pnd_rss', 'candid', 'manual');--> statement-breakpoint
CREATE TYPE "public"."grant_status" AS ENUM('open', 'expired', 'closed');--> statement-breakpoint
CREATE TYPE "public"."icp_band" AS ENUM('below', 'primary', 'above', 'unknown');--> statement-breakpoint
CREATE TYPE "public"."match_reject_reason" AS ENUM('wrong_eligibility', 'wrong_geography', 'bad_capacity_fit', 'weak_mission_fit', 'stale_deadline', 'bad_contact', 'other');--> statement-breakpoint
CREATE TYPE "public"."match_review_status" AS ENUM('pending', 'approved', 'rejected', 'edited');--> statement-breakpoint
CREATE TYPE "public"."pipeline_event_type" AS ENUM('enrolled', 'sent', 'opened', 'replied', 'bounced', 'unsubscribed', 'brief_requested', 'brief_sent', 'demo_booked', 'demo_held', 'pilot_started', 'converted');--> statement-breakpoint
CREATE TYPE "public"."registration_status" AS ENUM('good_standing', 'lapsed', 'unknown');--> statement-breakpoint
CREATE TABLE "contacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"org_id" uuid NOT NULL,
"full_name" text,
"title" text,
"email" text,
"email_status" "email_status" DEFAULT 'unverified' NOT NULL,
"source_provider" "contact_source_provider",
"priority" "contact_priority" DEFAULT 'generic' NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "grants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"funder" text NOT NULL,
"title" text NOT NULL,
"synopsis" text,
"eligibility_entity_types" text[],
"geographic_scope" text,
"program_areas" text[],
"award_floor" integer,
"award_ceiling" integer,
"expected_awards_count" integer,
"open_date" timestamp with time zone,
"close_date" timestamp with time zone,
"match_requirement" boolean DEFAULT false NOT NULL,
"application_effort_estimate" "application_effort_estimate" DEFAULT 'unknown' NOT NULL,
"application_form_supported" boolean DEFAULT false NOT NULL,
"source_url" text NOT NULL,
"source" "grant_source" NOT NULL,
"status" "grant_status" DEFAULT 'open' NOT NULL,
"synopsis_embedding" vector(1536),
"last_verified_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "matches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"org_id" uuid NOT NULL,
"grant_id" uuid NOT NULL,
"total_score" integer NOT NULL,
"subscores" jsonb,
"hard_gates_passed" boolean DEFAULT false NOT NULL,
"easy_win" boolean DEFAULT false NOT NULL,
"rationale" jsonb,
"review_status" "match_review_status" DEFAULT 'pending' NOT NULL,
"reject_reason" "match_reject_reason",
"is_hero" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "org_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"org_id" uuid NOT NULL,
"mission_statement" text,
"programs" jsonb,
"service_geography" text,
"recent_news" jsonb,
"known_funders" jsonb,
"staff" jsonb,
"budget_band" text,
"sources" jsonb,
"confidence" real,
"profile_embedding" vector(1536),
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "orgs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"city" text,
"state" text DEFAULT 'NH' NOT NULL,
"ein" text,
"ntee_code" text,
"total_revenue" integer,
"fiscal_year_end" text,
"registration_status" "registration_status" DEFAULT 'unknown' NOT NULL,
"icp_band" "icp_band" DEFAULT 'unknown' NOT NULL,
"source_registry" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "pipeline_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"org_id" uuid,
"contact_id" uuid,
"match_id" uuid,
"event_type" "pipeline_event_type" NOT NULL,
"payload" jsonb,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_grant_id_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "org_profiles" ADD CONSTRAINT "org_profiles_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pipeline_events" ADD CONSTRAINT "pipeline_events_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pipeline_events" ADD CONSTRAINT "pipeline_events_contact_id_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pipeline_events" ADD CONSTRAINT "pipeline_events_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_contacts_org" ON "contacts" USING btree ("org_id");--> statement-breakpoint
CREATE INDEX "idx_contacts_email" ON "contacts" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_grants_source_url" ON "grants" USING btree ("source_url");--> statement-breakpoint
CREATE INDEX "idx_grants_status" ON "grants" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_grants_close_date" ON "grants" USING btree ("close_date");--> statement-breakpoint
CREATE INDEX "idx_grants_source" ON "grants" USING btree ("source");--> statement-breakpoint
CREATE INDEX "grants_synopsis_embedding_idx" ON "grants" USING hnsw ("synopsis_embedding" vector_cosine_ops);--> statement-breakpoint
CREATE INDEX "idx_matches_org" ON "matches" USING btree ("org_id");--> statement-breakpoint
CREATE INDEX "idx_matches_grant" ON "matches" USING btree ("grant_id");--> statement-breakpoint
CREATE INDEX "idx_matches_review_status" ON "matches" USING btree ("review_status");--> statement-breakpoint
CREATE INDEX "idx_org_profiles_org" ON "org_profiles" USING btree ("org_id");--> statement-breakpoint
CREATE INDEX "org_profiles_embedding_idx" ON "org_profiles" USING hnsw ("profile_embedding" vector_cosine_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_orgs_ein" ON "orgs" USING btree ("ein") WHERE "orgs"."ein" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_orgs_icp_band" ON "orgs" USING btree ("icp_band");--> statement-breakpoint
CREATE INDEX "idx_orgs_state" ON "orgs" USING btree ("state");--> statement-breakpoint
CREATE INDEX "idx_pipeline_events_org" ON "pipeline_events" USING btree ("org_id");--> statement-breakpoint
CREATE INDEX "idx_pipeline_events_contact" ON "pipeline_events" USING btree ("contact_id");--> statement-breakpoint
CREATE INDEX "idx_pipeline_events_match" ON "pipeline_events" USING btree ("match_id");--> statement-breakpoint
CREATE INDEX "idx_pipeline_events_type_occurred" ON "pipeline_events" USING btree ("event_type","occurred_at");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784214387212,
"tag": "1784214387_initial-schema",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,46 @@
{
"name": "@novelpad/outreach-core",
"packageManager": "yarn@4.5.0",
"sideEffects": false,
"type": "module",
"version": "1.0.0",
"private": true,
"module": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"create:migration": "bin/create-migration",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio"
},
"imports": {
"#~/*": "./dist/*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
}
},
"dependencies": {
"drizzle-orm": "0.44.6",
"pg": "8.20.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@novelpad/config": "workspace:^",
"@types/node": "^22",
"@types/pg": "8.20.0",
"drizzle-kit": "^0.31.5",
"npm-run-all": "^4.1.5",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

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

View File

@@ -0,0 +1,15 @@
{
"extends": "@novelpad/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"types": ["node"],
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"paths": {
"#~/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}