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

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

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

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

View File

@@ -0,0 +1,53 @@
# @novelpad/outreach-review
Human review queue for (org, grant) matches produced by the scoring pipeline in `packages/outreach-core`. A reviewer sees each pending match — organization, hero grant, total score, easy-win flag — and approves or rejects it. Approved matches are picked up downstream and synced into Apollo sequences.
## Auth
**There is no authentication in this app, by design.** It is a tiny internal tool intended to run behind:
- Google IAP (Identity-Aware Proxy) in any hosted environment, or
- `localhost` only, for local development
Do **not** add `better-auth` (or any other auth library) to this workspace. If this app ever needs to leave an IAP-fronted network, that's a deliberate follow-up decision, not something to bolt on ad hoc here.
## Development
```
yarn dev # react-router dev
yarn build # react-router build
yarn start # serve the production build
yarn typecheck # react-router typegen && tsc --noEmit
```
Requires `DATABASE_URL` pointing at the outreach Postgres instance (see `packages/outreach-core`). Optional: `PG_POOL_MAX`, `PG_CONNECTION_TIMEOUT_MS`.
## Data contract
This app is a thin consumer of `@novelpad/outreach-core`. It assumes the following exports (server-side query/action functions live under the `./server` subpath, matching the `packages/core` convention in novelpad-desktop):
```ts
// @novelpad/outreach-core
export const schema: /* drizzle schema */;
export type NpOutreachDatabase = /* PgDatabase<..., typeof schema> */;
export interface PendingReviewMatch {
id: string;
orgName: string;
heroGrantTitle: string;
totalScore: number;
isEasyWin: boolean;
}
export type MatchReviewDecision = 'approved' | 'rejected';
// @novelpad/outreach-core/server
export function serverListPendingReviewMatches(
db: NpOutreachDatabase,
): Promise<PendingReviewMatch[]>;
export function serverSetMatchReview(
db: NpOutreachDatabase,
matchId: string,
decision: MatchReviewDecision,
): Promise<void>;
```
If `packages/outreach-core`'s matches domain (`src/matches/queries`, `src/matches/actions`) lands with different names or a different `PendingReviewMatch` shape, update `app/db.server.ts` and `app/routes/_index.tsx` to match rather than reshaping data client-side.

View File

@@ -0,0 +1,7 @@
/* Deliberately font-free (see root.tsx) — this app doesn't pull in
@novelpad/config/shadcn.css because that file's first several lines are
@fontsource imports. Keeping the review queue on system fonts avoids
shipping the full novelpad-desktop font set for a tiny internal tool. */
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,33 @@
// Copied/adapted from novelpad-desktop apps/website/app/db/db.server.ts @ 62c56b87
import { schema, type NpOutreachDatabase } from '@novelpad/outreach-core';
import { drizzle } from 'drizzle-orm/node-postgres';
import pkg from 'pg';
const { Pool } = pkg;
// HMR-safe singleton. Without this, Vite re-evaluates this module on hot
// reload and instantiates a fresh Pool, while the previous pool lingers with
// open connections until idleTimeoutMillis fires — see the equivalent note
// in novelpad-desktop's apps/website/app/db/db.server.ts.
const g = globalThis as unknown as {
__outreachPool?: InstanceType<typeof Pool>;
__outreachDb?: NpOutreachDatabase;
};
const pool =
g.__outreachPool ??
(g.__outreachPool = new Pool({
connectionString: process.env.DATABASE_URL,
application_name: 'outreach-review',
max: Number(process.env.PG_POOL_MAX ?? 10),
idleTimeoutMillis: 30000,
connectionTimeoutMillis: Number(
process.env.PG_CONNECTION_TIMEOUT_MS ?? 15000,
),
}));
const db =
g.__outreachDb ??
(g.__outreachDb = drizzle(pool, { schema }) as unknown as NpOutreachDatabase);
export { db, pool };

View File

@@ -0,0 +1,56 @@
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts } from 'react-router';
import type { Route } from './+types/root.js';
import appCssUrl from './assets/main.css?url';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HelmDocs Outreach Review</title>
<link rel="stylesheet" href={appCssUrl} />
<Meta />
<Links />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404
? 'The requested page could not be found.'
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -0,0 +1,3 @@
import { index, type RouteConfig } from '@react-router/dev/routes';
export default [index('routes/_index.tsx')] satisfies RouteConfig;

View File

@@ -0,0 +1,109 @@
import {
serverListPendingReviewMatches,
serverSetMatchReview,
} from '@novelpad/outreach-core/server';
import { Form } from 'react-router';
import { db } from '#~/db.server.js';
import type { Route } from './+types/_index.js';
export async function loader({ request }: Route.LoaderArgs) {
if (request.method === 'HEAD') return;
const matches = await serverListPendingReviewMatches(db);
return { matches };
}
export async function action({ request }: Route.ActionArgs) {
if (request.method === 'HEAD') return;
const formData = await request.formData();
const matchId = String(formData.get('matchId') ?? '');
const decision = formData.get('decision');
if (!matchId || (decision !== 'approved' && decision !== 'rejected')) {
throw new Response('Invalid review submission', { status: 400 });
}
await serverSetMatchReview(db, {
matchId,
reviewStatus: decision,
rejectReason: decision === 'rejected' ? 'other' : undefined,
});
return { ok: true as const };
}
export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
const matches = loaderData?.matches ?? [];
return (
<main className="container mx-auto p-6">
<h1 className="mb-4 text-2xl font-semibold">Grant Match Review Queue</h1>
{matches.length === 0 ? (
<p role="status" className="text-gray-600">
No pending matches to review.
</p>
) : (
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b">
<th scope="col" className="p-2">
Organization
</th>
<th scope="col" className="p-2">
Grant
</th>
<th scope="col" className="p-2">
Score
</th>
<th scope="col" className="p-2">
Easy Win
</th>
<th scope="col" className="p-2">
Actions
</th>
</tr>
</thead>
<tbody>
{matches.map((match) => (
<tr key={match.id} className="border-b">
<td className="p-2">{match.orgName}</td>
<td className="p-2">{match.grantTitle}</td>
<td className="p-2">{match.totalScore}</td>
<td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td>
<td className="p-2">
<div className="flex gap-2">
<Form method="post">
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="decision" value="approved" />
<button
type="submit"
aria-label={`Approve match for ${match.orgName}`}
className="rounded bg-green-600 px-3 py-1 text-white hover:bg-green-700"
>
Approve
</button>
</Form>
<Form method="post">
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="decision" value="rejected" />
<button
type="submit"
aria-label={`Reject match for ${match.orgName}`}
className="rounded bg-red-600 px-3 py-1 text-white hover:bg-red-700"
>
Reject
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}

View File

@@ -0,0 +1,40 @@
{
"name": "@novelpad/outreach-review",
"packageManager": "yarn@4.5.0",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
},
"imports": {
"#~/*": "./app/*"
},
"dependencies": {
"@novelpad/outreach-core": "workspace:^",
"@react-router/node": "^7.9.1",
"@react-router/serve": "^7.9.1",
"drizzle-orm": "0.44.6",
"isbot": "4",
"pg": "8.20.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.9.1"
},
"devDependencies": {
"@novelpad/config": "workspace:^",
"@react-router/dev": "^7.9.1",
"@types/node": "^22",
"@types/pg": "8.20.0",
"@types/react": "^19.0.9",
"@types/react-dom": "^19.0.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.9.3",
"vite": "^5.4.9"
}
}

View File

@@ -0,0 +1,5 @@
import type { Config } from '@react-router/dev/config';
export default {
ssr: true,
} satisfies Config;

View File

@@ -0,0 +1,7 @@
import tailwindConfig from '@novelpad/config/tailwind.config.js';
import type { Config } from 'tailwindcss';
export default {
...tailwindConfig,
content: ['./app/**/*.{js,ts,jsx,tsx}'],
} satisfies Config;

View File

@@ -0,0 +1,28 @@
{
"extends": "@novelpad/config/tsconfig.base.json",
"include": [
"app/**/*.ts",
"app/**/*.tsx",
".react-router/types/**/*"
],
"exclude": ["node_modules", "build"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"#~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": false
}
}

View File

@@ -0,0 +1,21 @@
import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter()],
build: {
target: 'es2022',
sourcemap: process.env.NODE_ENV !== 'production',
},
ssr: {
// pg is Node-only (uses net/tls) and should load from Node's module
// resolver rather than Vite's SSR bundler, matching apps/website.
external: ['pg'],
},
css: {
postcss: {
plugins: [tailwindcss()],
},
},
});