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:
1
packages/config/README.md
Normal file
1
packages/config/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# tsconfig
|
||||
21
packages/config/package.json
Normal file
21
packages/config/package.json
Normal 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
710
packages/config/shadcn.css
Normal 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);
|
||||
}
|
||||
125
packages/config/tailwind.config.ts
Normal file
125
packages/config/tailwind.config.ts
Normal 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;
|
||||
17
packages/config/tsconfig.base.json
Normal file
17
packages/config/tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
32
packages/config/vite-plugins/react-virtualized.js
vendored
Normal file
32
packages/config/vite-plugins/react-virtualized.js
vendored
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
54
packages/outreach-ai/README.md
Normal file
54
packages/outreach-ai/README.md
Normal 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.
|
||||
32
packages/outreach-ai/package.json
Normal file
32
packages/outreach-ai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
packages/outreach-ai/src/agents/mission-fit-judge/run.ts
Normal file
64
packages/outreach-ai/src/agents/mission-fit-judge/run.ts
Normal 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');
|
||||
}
|
||||
20
packages/outreach-ai/src/agents/mission-fit-judge/schema.ts
Normal file
20
packages/outreach-ai/src/agents/mission-fit-judge/schema.ts
Normal 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>;
|
||||
52
packages/outreach-ai/src/agents/org-profiler/run.ts
Normal file
52
packages/outreach-ai/src/agents/org-profiler/run.ts
Normal 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');
|
||||
}
|
||||
56
packages/outreach-ai/src/agents/org-profiler/schema.test.ts
Normal file
56
packages/outreach-ai/src/agents/org-profiler/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/outreach-ai/src/agents/org-profiler/schema.ts
Normal file
36
packages/outreach-ai/src/agents/org-profiler/schema.ts
Normal 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>;
|
||||
61
packages/outreach-ai/src/embeddings.ts
Normal file
61
packages/outreach-ai/src/embeddings.ts
Normal 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;
|
||||
}
|
||||
67
packages/outreach-ai/src/gemini.ts
Normal file
67
packages/outreach-ai/src/gemini.ts
Normal 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';
|
||||
7
packages/outreach-ai/src/index.ts
Normal file
7
packages/outreach-ai/src/index.ts
Normal 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';
|
||||
18
packages/outreach-ai/src/models.ts
Normal file
18
packages/outreach-ai/src/models.ts
Normal 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';
|
||||
21
packages/outreach-ai/tsconfig.json
Normal file
21
packages/outreach-ai/tsconfig.json
Normal 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"]
|
||||
}
|
||||
54
packages/outreach-core/README.md
Normal file
54
packages/outreach-core/README.md
Normal 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
|
||||
```
|
||||
16
packages/outreach-core/bin/create-migration
Executable file
16
packages/outreach-core/bin/create-migration
Executable 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}"
|
||||
14
packages/outreach-core/drizzle.config.ts
Normal file
14
packages/outreach-core/drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
1120
packages/outreach-core/drizzle/server/meta/1784214387_snapshot.json
Normal file
1120
packages/outreach-core/drizzle/server/meta/1784214387_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
packages/outreach-core/drizzle/server/meta/_journal.json
Normal file
13
packages/outreach-core/drizzle/server/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784214387212,
|
||||
"tag": "1784214387_initial-schema",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
46
packages/outreach-core/package.json
Normal file
46
packages/outreach-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/outreach-core/src/db/db.ts
Normal file
17
packages/outreach-core/src/db/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { PgQueryResultHKT, PgTransaction } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { schema } from './schema.js';
|
||||
|
||||
export { schema };
|
||||
|
||||
/**
|
||||
* The outreach-core database handle. This package is server-only — there is
|
||||
* no client/local database, so unlike @novelpad/core there is only ever one
|
||||
* of these (no `NpClientDatabase` counterpart).
|
||||
*/
|
||||
export type NpOutreachDatabase = NodePgDatabase<typeof schema>;
|
||||
|
||||
export type NpOutreachTransaction<
|
||||
TQueryResult extends PgQueryResultHKT = PgQueryResultHKT,
|
||||
> = PgTransaction<TQueryResult, typeof schema>;
|
||||
2
packages/outreach-core/src/db/index.ts
Normal file
2
packages/outreach-core/src/db/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './db.js';
|
||||
export * from './schema.js';
|
||||
339
packages/outreach-core/src/db/schema.ts
Normal file
339
packages/outreach-core/src/db/schema.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
real,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
vector,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const applicationEffortEnum = pgEnum('application_effort_estimate', [
|
||||
'loi_only',
|
||||
'short_form',
|
||||
'full_federal',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
export const grantSourceEnum = pgEnum('grant_source', [
|
||||
'grants_gov',
|
||||
'nh_state',
|
||||
'irs_990pf',
|
||||
'pnd_rss',
|
||||
'candid',
|
||||
'manual',
|
||||
]);
|
||||
|
||||
export const grantStatusEnum = pgEnum('grant_status', [
|
||||
'open',
|
||||
'expired',
|
||||
'closed',
|
||||
]);
|
||||
|
||||
export const registrationStatusEnum = pgEnum('registration_status', [
|
||||
'good_standing',
|
||||
'lapsed',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
export const icpBandEnum = pgEnum('icp_band', [
|
||||
'below',
|
||||
'primary',
|
||||
'above',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
export const emailStatusEnum = pgEnum('email_status', [
|
||||
'unverified',
|
||||
'valid',
|
||||
'risky',
|
||||
'invalid',
|
||||
]);
|
||||
|
||||
export const contactSourceEnum = pgEnum('contact_source_provider', [
|
||||
'apollo',
|
||||
'irs_990',
|
||||
'website',
|
||||
'manual',
|
||||
]);
|
||||
|
||||
export const contactPriorityEnum = pgEnum('contact_priority', [
|
||||
'named',
|
||||
'generic',
|
||||
]);
|
||||
|
||||
export const matchReviewStatusEnum = pgEnum('match_review_status', [
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'edited',
|
||||
]);
|
||||
|
||||
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
|
||||
'wrong_eligibility',
|
||||
'wrong_geography',
|
||||
'bad_capacity_fit',
|
||||
'weak_mission_fit',
|
||||
'stale_deadline',
|
||||
'bad_contact',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const pipelineEventTypeEnum = pgEnum('pipeline_event_type', [
|
||||
'enrolled',
|
||||
'sent',
|
||||
'opened',
|
||||
'replied',
|
||||
'bounced',
|
||||
'unsubscribed',
|
||||
'brief_requested',
|
||||
'brief_sent',
|
||||
'demo_booked',
|
||||
'demo_held',
|
||||
'pilot_started',
|
||||
'converted',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// grants — open grant opportunities ingested from Grants.gov, NH state
|
||||
// sources, IRS 990-PF filings, Philanthropy News Digest RSS, and Candid.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const grants = pgTable(
|
||||
'grants',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
funder: text('funder').notNull(),
|
||||
title: text('title').notNull(),
|
||||
synopsis: text('synopsis'),
|
||||
eligibilityEntityTypes: text('eligibility_entity_types').array(),
|
||||
geographicScope: text('geographic_scope'),
|
||||
// NTEE codes, e.g. ['A', 'B20', 'P']
|
||||
programAreas: text('program_areas').array(),
|
||||
awardFloor: integer('award_floor'),
|
||||
awardCeiling: integer('award_ceiling'),
|
||||
expectedAwardsCount: integer('expected_awards_count'),
|
||||
openDate: timestamp('open_date', { withTimezone: true }),
|
||||
closeDate: timestamp('close_date', { withTimezone: true }),
|
||||
matchRequirement: boolean('match_requirement').notNull().default(false),
|
||||
applicationEffortEstimate: applicationEffortEnum(
|
||||
'application_effort_estimate',
|
||||
)
|
||||
.notNull()
|
||||
.default('unknown'),
|
||||
applicationFormSupported: boolean('application_form_supported')
|
||||
.notNull()
|
||||
.default(false),
|
||||
sourceUrl: text('source_url').notNull(),
|
||||
source: grantSourceEnum('source').notNull(),
|
||||
status: grantStatusEnum('status').notNull().default('open'),
|
||||
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
|
||||
lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Upsert key: sources are re-crawled and re-ingested on a schedule.
|
||||
uniqueIndex('idx_grants_source_url').on(t.sourceUrl),
|
||||
index('idx_grants_status').on(t.status),
|
||||
index('idx_grants_close_date').on(t.closeDate),
|
||||
index('idx_grants_source').on(t.source),
|
||||
index('grants_synopsis_embedding_idx').using(
|
||||
'hnsw',
|
||||
t.synopsisEmbedding.op('vector_cosine_ops'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgs — New Hampshire nonprofits being profiled and matched against grants.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const orgs = pgTable(
|
||||
'orgs',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
name: text('name').notNull(),
|
||||
city: text('city'),
|
||||
state: text('state').notNull().default('NH'),
|
||||
ein: text('ein'),
|
||||
nteeCode: text('ntee_code'),
|
||||
totalRevenue: integer('total_revenue'),
|
||||
fiscalYearEnd: text('fiscal_year_end'),
|
||||
registrationStatus: registrationStatusEnum('registration_status')
|
||||
.notNull()
|
||||
.default('unknown'),
|
||||
icpBand: icpBandEnum('icp_band').notNull().default('unknown'),
|
||||
sourceRegistry: text('source_registry'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Partial unique index: EIN is unique when present, but many small
|
||||
// orgs in early ingestion won't have one resolved yet.
|
||||
uniqueIndex('idx_orgs_ein').on(t.ein).where(sql`${t.ein} IS NOT NULL`),
|
||||
index('idx_orgs_icp_band').on(t.icpBand),
|
||||
index('idx_orgs_state').on(t.state),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// org_profiles — LLM/agent-researched enrichment for an org.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const orgProfiles = pgTable(
|
||||
'org_profiles',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
orgId: uuid('org_id')
|
||||
.notNull()
|
||||
.references(() => orgs.id, { onDelete: 'cascade' }),
|
||||
missionStatement: text('mission_statement'),
|
||||
programs: jsonb('programs'),
|
||||
serviceGeography: text('service_geography'),
|
||||
recentNews: jsonb('recent_news'),
|
||||
knownFunders: jsonb('known_funders'),
|
||||
staff: jsonb('staff'),
|
||||
budgetBand: text('budget_band'),
|
||||
// Citation URLs backing the researched fields above.
|
||||
sources: jsonb('sources'),
|
||||
confidence: real('confidence'),
|
||||
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_org_profiles_org').on(t.orgId),
|
||||
index('org_profiles_embedding_idx').using(
|
||||
'hnsw',
|
||||
t.profileEmbedding.op('vector_cosine_ops'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contacts — people at an org, sourced from Apollo, 990 filings, or the
|
||||
// org's own website.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const contacts = pgTable(
|
||||
'contacts',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
orgId: uuid('org_id')
|
||||
.notNull()
|
||||
.references(() => orgs.id, { onDelete: 'cascade' }),
|
||||
fullName: text('full_name'),
|
||||
title: text('title'),
|
||||
email: text('email'),
|
||||
emailStatus: emailStatusEnum('email_status').notNull().default('unverified'),
|
||||
sourceProvider: contactSourceEnum('source_provider'),
|
||||
priority: contactPriorityEnum('priority').notNull().default('generic'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_contacts_org').on(t.orgId),
|
||||
index('idx_contacts_email').on(t.email),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matches — a scored (org, grant) pair, gated deterministically and then
|
||||
// weighted by LLM subscores, queued for human review.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const matches = pgTable(
|
||||
'matches',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
orgId: uuid('org_id')
|
||||
.notNull()
|
||||
.references(() => orgs.id, { onDelete: 'cascade' }),
|
||||
grantId: uuid('grant_id')
|
||||
.notNull()
|
||||
.references(() => grants.id, { onDelete: 'cascade' }),
|
||||
totalScore: integer('total_score').notNull(),
|
||||
subscores: jsonb('subscores'),
|
||||
hardGatesPassed: boolean('hard_gates_passed').notNull().default(false),
|
||||
easyWin: boolean('easy_win').notNull().default(false),
|
||||
// LLM-produced citations backing the score/subscores.
|
||||
rationale: jsonb('rationale'),
|
||||
reviewStatus: matchReviewStatusEnum('review_status')
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
rejectReason: matchRejectReasonEnum('reject_reason'),
|
||||
isHero: boolean('is_hero').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_matches_org').on(t.orgId),
|
||||
index('idx_matches_grant').on(t.grantId),
|
||||
index('idx_matches_review_status').on(t.reviewStatus),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pipeline_events — outreach lifecycle events synced back from Apollo (and
|
||||
// recorded internally) for an org/contact/match.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const pipelineEvents = pgTable(
|
||||
'pipeline_events',
|
||||
{
|
||||
id: uuid('id')
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
orgId: uuid('org_id').references(() => orgs.id, { onDelete: 'set null' }),
|
||||
contactId: uuid('contact_id').references(() => contacts.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
matchId: uuid('match_id').references(() => matches.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
eventType: pipelineEventTypeEnum('event_type').notNull(),
|
||||
payload: jsonb('payload'),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_pipeline_events_org').on(t.orgId),
|
||||
index('idx_pipeline_events_contact').on(t.contactId),
|
||||
index('idx_pipeline_events_match').on(t.matchId),
|
||||
index('idx_pipeline_events_type_occurred').on(t.eventType, t.occurredAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema barrel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const schema = {
|
||||
grants,
|
||||
orgs,
|
||||
orgProfiles,
|
||||
contacts,
|
||||
matches,
|
||||
pipelineEvents,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, eq, lt, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* Marks every currently-`open` grant whose `closeDate` has passed as
|
||||
* `expired`, so it drops out of the active match/scoring pool. Grants with
|
||||
* no `closeDate` (rolling/LOI-only programs) are left untouched.
|
||||
*
|
||||
* Returns the ids that were flipped, for logging/observability.
|
||||
*/
|
||||
export async function serverExpireClosedGrants(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
): Promise<string[]> {
|
||||
const rows = await db
|
||||
.update(schema.grants)
|
||||
.set({ status: 'expired', updatedAt: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.grants.status, 'open'),
|
||||
lt(schema.grants.closeDate, sql`now()`),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.grants.id });
|
||||
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './expire-closed-grants.server.js';
|
||||
export * from './insert-grants.server.js';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* Insertable shape for a single grant, as produced by a source-specific
|
||||
* ingestion step (Grants.gov, NH state, 990-PF extracts, ...) after
|
||||
* normalization to our schema's columns.
|
||||
*/
|
||||
export type NewGrantInput = Omit<
|
||||
typeof schema.grants.$inferInsert,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Upserts a batch of grants, keyed on `sourceUrl` — the stable identifier
|
||||
* across re-crawls of the same upstream listing. Existing rows are
|
||||
* refreshed in place (title/dates/amounts can change between crawls);
|
||||
* `status` is also re-set from the incoming payload so a grant that was
|
||||
* re-opened upstream comes back out of `expired`/`closed`.
|
||||
*/
|
||||
export async function serverInsertGrants(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
grants: ReadonlyArray<NewGrantInput>,
|
||||
): Promise<void> {
|
||||
if (grants.length === 0) return;
|
||||
|
||||
await db
|
||||
.insert(schema.grants)
|
||||
.values([...grants])
|
||||
.onConflictDoUpdate({
|
||||
target: schema.grants.sourceUrl,
|
||||
set: {
|
||||
funder: sql`excluded.funder`,
|
||||
title: sql`excluded.title`,
|
||||
synopsis: sql`excluded.synopsis`,
|
||||
eligibilityEntityTypes: sql`excluded.eligibility_entity_types`,
|
||||
geographicScope: sql`excluded.geographic_scope`,
|
||||
programAreas: sql`excluded.program_areas`,
|
||||
awardFloor: sql`excluded.award_floor`,
|
||||
awardCeiling: sql`excluded.award_ceiling`,
|
||||
expectedAwardsCount: sql`excluded.expected_awards_count`,
|
||||
openDate: sql`excluded.open_date`,
|
||||
closeDate: sql`excluded.close_date`,
|
||||
matchRequirement: sql`excluded.match_requirement`,
|
||||
applicationEffortEstimate: sql`excluded.application_effort_estimate`,
|
||||
applicationFormSupported: sql`excluded.application_form_supported`,
|
||||
source: sql`excluded.source`,
|
||||
status: sql`excluded.status`,
|
||||
lastVerifiedAt: sql`excluded.last_verified_at`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
2
packages/outreach-core/src/grants/index.server.ts
Normal file
2
packages/outreach-core/src/grants/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-open-grants.server.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* All grants currently in `open` status, most-recently-closing first isn't
|
||||
* assumed here — callers needing a particular ordering (e.g. soonest
|
||||
* deadline) should `.orderBy` on the returned query builder.
|
||||
*/
|
||||
export function serverListOpenGrants(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
) {
|
||||
return db.select().from(schema.grants).where(eq(schema.grants.status, 'open'));
|
||||
}
|
||||
6
packages/outreach-core/src/index.server.ts
Normal file
6
packages/outreach-core/src/index.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './index.js';
|
||||
|
||||
export * from './grants/index.server.js';
|
||||
export * from './matches/index.server.js';
|
||||
export * from './orgs/index.server.js';
|
||||
export * from './pipeline/index.server.js';
|
||||
9
packages/outreach-core/src/index.ts
Normal file
9
packages/outreach-core/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Client-safe barrel: schema, DB types, and pure domain logic only. This
|
||||
// package is server-only (no client/local database), so unlike
|
||||
// @novelpad/core there is no synced/client schema here — this export
|
||||
// exists purely so consumers that only need the table definitions/types
|
||||
// (e.g. a Drizzle `drizzle(pool, { schema })` construction site, or a
|
||||
// worker sharing types with an action call) don't have to pull in every
|
||||
// server-only action/query.
|
||||
export * from './db/index.js';
|
||||
export * from './matches/hard-gates.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './insert-match.server.js';
|
||||
export * from './set-match-review.server.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export type NewMatchInput = Omit<
|
||||
typeof schema.matches.$inferInsert,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'reviewStatus'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Records a scored (org, grant) match. Always lands in `reviewStatus:
|
||||
* 'pending'` — the review decision is a separate step via
|
||||
* `serverSetMatchReview`.
|
||||
*/
|
||||
export async function serverInsertMatch(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
match: NewMatchInput,
|
||||
): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(schema.matches)
|
||||
.values({ ...match, reviewStatus: 'pending' })
|
||||
.returning({ id: schema.matches.id });
|
||||
|
||||
if (row == null) {
|
||||
throw new Error('serverInsertMatch: insert returned no row');
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
type ReviewStatus = (typeof schema.matches.$inferSelect)['reviewStatus'];
|
||||
type RejectReason = (typeof schema.matches.$inferSelect)['rejectReason'];
|
||||
|
||||
/**
|
||||
* Records a human reviewer's decision on a match. `rejectReason` is only
|
||||
* meaningful (and only ever set) when `reviewStatus` is `'rejected'` — any
|
||||
* other status clears it, so a previously-rejected match doesn't carry a
|
||||
* stale reason if it's later re-approved after editing.
|
||||
*/
|
||||
export async function serverSetMatchReview(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{
|
||||
matchId,
|
||||
reviewStatus,
|
||||
rejectReason,
|
||||
}: {
|
||||
matchId: string;
|
||||
reviewStatus: ReviewStatus;
|
||||
rejectReason?: RejectReason;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.matches)
|
||||
.set({
|
||||
reviewStatus,
|
||||
rejectReason: reviewStatus === 'rejected' ? (rejectReason ?? null) : null,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where(eq(schema.matches.id, matchId));
|
||||
}
|
||||
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal file
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
evaluateHardGates,
|
||||
type HardGateGrantInput,
|
||||
type HardGateOrgInput,
|
||||
MIN_AWARD_CEILING,
|
||||
} from './hard-gates.js';
|
||||
|
||||
const NOW = new Date('2026-07-16T00:00:00Z');
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(NOW.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const org: HardGateOrgInput = {
|
||||
entityType: '501c3',
|
||||
state: 'NH',
|
||||
};
|
||||
|
||||
const grant: HardGateGrantInput = {
|
||||
eligibilityEntityTypes: ['501c3', 'municipality'],
|
||||
geographicScope: 'New Hampshire',
|
||||
closeDate: daysFromNow(30),
|
||||
awardCeiling: 50_000,
|
||||
applicationFormSupported: true,
|
||||
};
|
||||
|
||||
describe('evaluateHardGates', () => {
|
||||
it('passes a fully-eligible match with no failures', () => {
|
||||
const result = evaluateHardGates(org, grant, { now: NOW });
|
||||
expect(result).toEqual({ passed: true, failures: [] });
|
||||
});
|
||||
|
||||
it('treats an unrestricted entity-type list as eligible', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, eligibilityEntityTypes: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('ineligible_entity_type');
|
||||
});
|
||||
|
||||
it('fails when the org entity type is not in the eligibility list', () => {
|
||||
const result = evaluateHardGates(
|
||||
{ ...org, entityType: 'llc' },
|
||||
grant,
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('ineligible_entity_type');
|
||||
});
|
||||
|
||||
it('passes a national-scope grant regardless of org state', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, geographicScope: 'National' },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('geography_mismatch');
|
||||
});
|
||||
|
||||
it('fails when the geographic scope excludes the org state', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, geographicScope: 'California' },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('geography_mismatch');
|
||||
});
|
||||
|
||||
it('fails when the deadline is fewer than 21 days out', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: daysFromNow(20) },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('passes when the deadline is exactly 21 days out', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: daysFromNow(21) },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('fails when there is no close date at all', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('fails when the award ceiling is below the minimum', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, awardCeiling: MIN_AWARD_CEILING - 1 },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('award_ceiling_too_low');
|
||||
});
|
||||
|
||||
it('fails when the award ceiling is unknown', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, awardCeiling: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('award_ceiling_too_low');
|
||||
});
|
||||
|
||||
it('fails when the application form is not supported', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, applicationFormSupported: false },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('application_form_unsupported');
|
||||
});
|
||||
|
||||
it('accumulates every failing gate, not just the first', () => {
|
||||
const result = evaluateHardGates(
|
||||
{ entityType: 'llc', state: 'NH' },
|
||||
{
|
||||
eligibilityEntityTypes: ['501c3'],
|
||||
geographicScope: 'California',
|
||||
closeDate: null,
|
||||
awardCeiling: null,
|
||||
applicationFormSupported: false,
|
||||
},
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toEqual([
|
||||
'ineligible_entity_type',
|
||||
'geography_mismatch',
|
||||
'deadline_too_soon',
|
||||
'award_ceiling_too_low',
|
||||
'application_form_unsupported',
|
||||
]);
|
||||
});
|
||||
});
|
||||
173
packages/outreach-core/src/matches/hard-gates.ts
Normal file
173
packages/outreach-core/src/matches/hard-gates.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Deterministic SQL-equivalent hard gates for an (org, grant) match.
|
||||
*
|
||||
* These are cheap, unambiguous pass/fail checks evaluated BEFORE the
|
||||
* weighted LLM subscoring pass — a match that fails any hard gate is never
|
||||
* worth spending an LLM call on. Kept as a pure function over plain data
|
||||
* (no DB dependency) so it can be unit tested directly and reused from a
|
||||
* SQL-backed batch job, a single-match rescoring step, or a preview in the
|
||||
* review UI without threading a database handle through it.
|
||||
*/
|
||||
|
||||
export interface HardGateOrgInput {
|
||||
/** e.g. '501c3', 'municipality', 'school_district' */
|
||||
readonly entityType: string;
|
||||
/** Two-letter state code, e.g. 'NH'. */
|
||||
readonly state: string;
|
||||
}
|
||||
|
||||
export interface HardGateGrantInput {
|
||||
/** Entity types the funder will accept; null/empty means unrestricted. */
|
||||
readonly eligibilityEntityTypes: readonly string[] | null;
|
||||
/** Free-text geographic scope, e.g. 'NH', 'New England', 'National'. */
|
||||
readonly geographicScope: string | null;
|
||||
readonly closeDate: Date | null;
|
||||
readonly awardCeiling: number | null;
|
||||
readonly applicationFormSupported: boolean;
|
||||
}
|
||||
|
||||
export type HardGateFailureReason =
|
||||
| 'ineligible_entity_type'
|
||||
| 'geography_mismatch'
|
||||
| 'deadline_too_soon'
|
||||
| 'award_ceiling_too_low'
|
||||
| 'application_form_unsupported';
|
||||
|
||||
export interface HardGateResult {
|
||||
readonly passed: boolean;
|
||||
readonly failures: readonly HardGateFailureReason[];
|
||||
}
|
||||
|
||||
/** A match's runway must clear this many days before the close date. */
|
||||
export const MIN_DAYS_TO_DEADLINE = 21;
|
||||
|
||||
/** Grants below this ceiling aren't worth the outreach effort. */
|
||||
export const MIN_AWARD_CEILING = 10_000;
|
||||
|
||||
const NATIONAL_SCOPE_KEYWORDS = [
|
||||
'national',
|
||||
'nationwide',
|
||||
'united states',
|
||||
'usa',
|
||||
];
|
||||
|
||||
/** Full state names, for scopes that spell the state out ('New Hampshire'). */
|
||||
const STATE_NAMES: Record<string, string> = {
|
||||
AL: 'alabama',
|
||||
AK: 'alaska',
|
||||
AZ: 'arizona',
|
||||
AR: 'arkansas',
|
||||
CA: 'california',
|
||||
CO: 'colorado',
|
||||
CT: 'connecticut',
|
||||
DE: 'delaware',
|
||||
FL: 'florida',
|
||||
GA: 'georgia',
|
||||
HI: 'hawaii',
|
||||
ID: 'idaho',
|
||||
IL: 'illinois',
|
||||
IN: 'indiana',
|
||||
IA: 'iowa',
|
||||
KS: 'kansas',
|
||||
KY: 'kentucky',
|
||||
LA: 'louisiana',
|
||||
ME: 'maine',
|
||||
MD: 'maryland',
|
||||
MA: 'massachusetts',
|
||||
MI: 'michigan',
|
||||
MN: 'minnesota',
|
||||
MS: 'mississippi',
|
||||
MO: 'missouri',
|
||||
MT: 'montana',
|
||||
NE: 'nebraska',
|
||||
NV: 'nevada',
|
||||
NH: 'new hampshire',
|
||||
NJ: 'new jersey',
|
||||
NM: 'new mexico',
|
||||
NY: 'new york',
|
||||
NC: 'north carolina',
|
||||
ND: 'north dakota',
|
||||
OH: 'ohio',
|
||||
OK: 'oklahoma',
|
||||
OR: 'oregon',
|
||||
PA: 'pennsylvania',
|
||||
RI: 'rhode island',
|
||||
SC: 'south carolina',
|
||||
SD: 'south dakota',
|
||||
TN: 'tennessee',
|
||||
TX: 'texas',
|
||||
UT: 'utah',
|
||||
VT: 'vermont',
|
||||
VA: 'virginia',
|
||||
WA: 'washington',
|
||||
WV: 'west virginia',
|
||||
WI: 'wisconsin',
|
||||
WY: 'wyoming',
|
||||
DC: 'district of columbia',
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs every hard gate against an (org, grant) pair and returns which, if
|
||||
* any, failed. `now` is injectable for deterministic testing.
|
||||
*/
|
||||
export function evaluateHardGates(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
{ now = new Date() }: { now?: Date } = {},
|
||||
): HardGateResult {
|
||||
const failures: HardGateFailureReason[] = [];
|
||||
|
||||
if (!isEntityEligible(org, grant)) failures.push('ineligible_entity_type');
|
||||
if (!isGeographyEligible(org, grant)) failures.push('geography_mismatch');
|
||||
if (!hasSufficientRunway(grant, now)) failures.push('deadline_too_soon');
|
||||
if (!meetsAwardCeiling(grant)) failures.push('award_ceiling_too_low');
|
||||
if (!grant.applicationFormSupported) {
|
||||
failures.push('application_form_unsupported');
|
||||
}
|
||||
|
||||
return { passed: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
function isEntityEligible(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
): boolean {
|
||||
if (
|
||||
grant.eligibilityEntityTypes == null ||
|
||||
grant.eligibilityEntityTypes.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return grant.eligibilityEntityTypes.some(
|
||||
(entityType) => entityType.toLowerCase() === org.entityType.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function isGeographyEligible(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
): boolean {
|
||||
const scope = grant.geographicScope?.trim().toLowerCase();
|
||||
if (!scope) return true;
|
||||
if (NATIONAL_SCOPE_KEYWORDS.some((keyword) => scope.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
// Match the two-letter code only on word boundaries ('NH', 'NH-only') so
|
||||
// it can't fire on letters embedded inside another word, and also accept
|
||||
// the spelled-out state name ('New Hampshire').
|
||||
const code = org.state.trim().toLowerCase();
|
||||
if (new RegExp(`\\b${code}\\b`, 'i').test(scope)) return true;
|
||||
const fullName = STATE_NAMES[org.state.trim().toUpperCase()];
|
||||
return fullName != null && scope.includes(fullName);
|
||||
}
|
||||
|
||||
function hasSufficientRunway(grant: HardGateGrantInput, now: Date): boolean {
|
||||
if (grant.closeDate == null) return false;
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
const daysRemaining = (grant.closeDate.getTime() - now.getTime()) / msPerDay;
|
||||
return daysRemaining >= MIN_DAYS_TO_DEADLINE;
|
||||
}
|
||||
|
||||
function meetsAwardCeiling(grant: HardGateGrantInput): boolean {
|
||||
return grant.awardCeiling != null && grant.awardCeiling >= MIN_AWARD_CEILING;
|
||||
}
|
||||
6
packages/outreach-core/src/matches/index.server.ts
Normal file
6
packages/outreach-core/src/matches/index.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// `hard-gates.js` is client-safe (pure, no DB) and is exported from the
|
||||
// package's root `index.js` instead — not re-exported here to avoid an
|
||||
// ambiguous `export *` collision in `index.server.ts` (which re-exports
|
||||
// both `./index.js` and this file).
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-pending-review-matches.server.js';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* Pending matches joined to their org and grant — the shape the review
|
||||
* queue renders. One row per pending (org, grant) match, most useful
|
||||
* columns only; the full match row (subscores, rationale) can be fetched
|
||||
* by id when a detail view lands.
|
||||
*/
|
||||
export interface PendingReviewMatch {
|
||||
id: string;
|
||||
orgName: string;
|
||||
grantTitle: string;
|
||||
funder: string;
|
||||
totalScore: number;
|
||||
easyWin: boolean;
|
||||
isHero: boolean;
|
||||
closeDate: Date | null;
|
||||
}
|
||||
|
||||
export async function serverListPendingReviewMatches(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
): Promise<PendingReviewMatch[]> {
|
||||
return db
|
||||
.select({
|
||||
id: schema.matches.id,
|
||||
orgName: schema.orgs.name,
|
||||
grantTitle: schema.grants.title,
|
||||
funder: schema.grants.funder,
|
||||
totalScore: schema.matches.totalScore,
|
||||
easyWin: schema.matches.easyWin,
|
||||
isHero: schema.matches.isHero,
|
||||
closeDate: schema.grants.closeDate,
|
||||
})
|
||||
.from(schema.matches)
|
||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||
.where(eq(schema.matches.reviewStatus, 'pending'));
|
||||
}
|
||||
1
packages/outreach-core/src/orgs/actions/index.server.ts
Normal file
1
packages/outreach-core/src/orgs/actions/index.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './insert-org.server.js';
|
||||
30
packages/outreach-core/src/orgs/actions/insert-org.server.ts
Normal file
30
packages/outreach-core/src/orgs/actions/insert-org.server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export type NewOrgInput = Omit<
|
||||
typeof schema.orgs.$inferInsert,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Inserts a single NH nonprofit org. Callers that discover an org from a
|
||||
* registry re-scan should query for an existing `ein` match first — this
|
||||
* function does not upsert (EIN is only unique when present, which makes a
|
||||
* blind upsert-on-conflict ambiguous for the common case of an org sourced
|
||||
* without one).
|
||||
*/
|
||||
export async function serverInsertOrg(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
org: NewOrgInput,
|
||||
): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(schema.orgs)
|
||||
.values(org)
|
||||
.returning({ id: schema.orgs.id });
|
||||
|
||||
if (row == null) {
|
||||
throw new Error('serverInsertOrg: insert returned no row');
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
2
packages/outreach-core/src/orgs/index.server.ts
Normal file
2
packages/outreach-core/src/orgs/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
1
packages/outreach-core/src/orgs/queries/index.server.ts
Normal file
1
packages/outreach-core/src/orgs/queries/index.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './list-orgs-in-icp-band.server.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
type IcpBand = (typeof schema.orgs.$inferSelect)['icpBand'];
|
||||
|
||||
/** All orgs currently classified into the given ICP band. */
|
||||
export function serverListOrgsInIcpBand(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{ icpBand }: { icpBand: IcpBand },
|
||||
) {
|
||||
return db.select().from(schema.orgs).where(eq(schema.orgs.icpBand, icpBand));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './record-pipeline-event.server.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export type NewPipelineEventInput = Omit<
|
||||
typeof schema.pipelineEvents.$inferInsert,
|
||||
'id' | 'createdAt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Records an outreach lifecycle event (Apollo webhook, or an internally
|
||||
* generated milestone like `brief_requested`). `occurredAt` defaults to now
|
||||
* when the caller doesn't have an authoritative upstream timestamp (e.g. a
|
||||
* webhook payload that only carries the event, not the original send time).
|
||||
*/
|
||||
export async function serverRecordPipelineEvent(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
event: NewPipelineEventInput,
|
||||
): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(schema.pipelineEvents)
|
||||
.values({ occurredAt: new Date(), ...event })
|
||||
.returning({ id: schema.pipelineEvents.id });
|
||||
|
||||
if (row == null) {
|
||||
throw new Error('serverRecordPipelineEvent: insert returned no row');
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
2
packages/outreach-core/src/pipeline/index.server.ts
Normal file
2
packages/outreach-core/src/pipeline/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-pipeline-events-for-org.server.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/** An org's outreach event history, most recent first — for the org detail/timeline view. */
|
||||
export function serverListPipelineEventsForOrg(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{ orgId }: { orgId: string },
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.pipelineEvents)
|
||||
.where(eq(schema.pipelineEvents.orgId, orgId))
|
||||
.orderBy(desc(schema.pipelineEvents.occurredAt));
|
||||
}
|
||||
15
packages/outreach-core/tsconfig.json
Normal file
15
packages/outreach-core/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user