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:
7
apps/outreach-review/app/assets/main.css
Normal file
7
apps/outreach-review/app/assets/main.css
Normal 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;
|
||||
33
apps/outreach-review/app/db.server.ts
Normal file
33
apps/outreach-review/app/db.server.ts
Normal 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 };
|
||||
56
apps/outreach-review/app/root.tsx
Normal file
56
apps/outreach-review/app/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/outreach-review/app/routes.ts
Normal file
3
apps/outreach-review/app/routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { index, type RouteConfig } from '@react-router/dev/routes';
|
||||
|
||||
export default [index('routes/_index.tsx')] satisfies RouteConfig;
|
||||
109
apps/outreach-review/app/routes/_index.tsx
Normal file
109
apps/outreach-review/app/routes/_index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user