diff --git a/.env.example b/.env.example index cb06652..20f7686 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,18 @@ ANTHROPIC_API_KEY= # Overrides the default model (claude-opus-5). ANTHROPIC_MODEL= +# -------------------------------------------------------------------- email +# Account email only — verification links. Outreach never goes through this. +# +# Optional. Unset, verification links are written to the container log instead +# of being sent, so a fresh checkout can still complete a signup. Sending +# requires BOTH values; EMAIL_FROM must be on a domain verified with Resend. +RESEND_API_KEY= +EMAIL_FROM= +# Public origin used to build links that land in someone's inbox. Without it +# links point at localhost, which is correct locally and wrong in production. +APP_URL= + # -------------------------------------------------------------------- queue # Optional until queued jobs need durability. REDIS_URL= diff --git a/README.md b/README.md index 10e5fb7..eab0961 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ what exists. ```bash bun install bun run db:migrate # applies migrations to ./local.db -bun test # 265 tests +bun test # 395 tests bun run check # format, typecheck, test ``` @@ -41,9 +41,11 @@ fixture provider, so a fresh checkout works with an empty `.env`. apps/ api/ Hono service on /api/v1 web/ Next.js 16 mobile-first PWA - worker/ background jobs: signal expiry, rescoring, privacy work + server/ the single entrypoint: API, PWA and the background loop packages/ ai/ the only package that talks to a model: composer + quality gates + pipeline/ the discovery-to-queue chain and its background jobs + email/ account email only — verification links, never outreach domain/ canonical types — depends on nothing db/ Turso/libSQL client and migration runner policy/ the deterministic policy engine @@ -57,6 +59,10 @@ migrations/ forward-only .sql, applied in filename order docker/ one Dockerfile per deployable service ``` +The pipeline lives in a package rather than an app because both the API +(adding a prospect on demand) and the background loop run the same chain — an +app importing another app's source would make the dependency direction a lie. + ## The pipeline One GitHub handle goes all the way to a card in the approval queue: @@ -65,7 +71,8 @@ One GitHub handle goes all the way to a card in the approval queue: enrich → resolve identities → collect signals → score → recommend ``` -`apps/worker/src/pipeline.ts` runs it. GitHub first because it is free, its +`packages/pipeline/src/pipeline.ts` runs it, and `POST /api/v1/prospects` is +how a person starts it from the UI. GitHub first because it is free, its profiles carry links the person published themselves — `twitter_username`, `blog`, `company` — and developer tooling is the launch wedge. A real profile typically yields three linked identities before any paid provider is touched. diff --git a/apps/api/package.json b/apps/api/package.json index 61e4dff..37efb75 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,8 @@ "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", "@outreachgraph/domain": "workspace:*", + "@outreachgraph/email": "workspace:*", + "@outreachgraph/pipeline": "workspace:*", "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", "@outreachgraph/scoring": "workspace:*", diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 6f0c21d..8773e5b 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, test } from 'bun:test'; import type { Hono } from 'hono'; import { StubModel } from '@outreachgraph/ai'; +import { GitHubProvider } from '@outreachgraph/providers'; +import type { Mailer, Message } from '@outreachgraph/email'; import { createApp } from './app'; import type { AppEnv, RequestActor } from './context'; import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; @@ -585,3 +587,385 @@ describe('drafting on demand (PRD §14)', () => { expect(rows.rows).toHaveLength(1); }); }); + +// ---------------------------------------------------------------- prospects + +/** A GitHub profile carrying the self-declared cross-links the resolver uses. */ +const GH_PROFILE = { + login: 'alexchen', + id: 4242, + name: 'Alex Chen', + company: '@Loopwright', + blog: 'https://loopwright.io', + location: 'Berlin', + email: null, + bio: 'Agent reliability', + twitter_username: 'alexbuilds', + public_repos: 30, + followers: 500, + html_url: 'https://github.com/alexchen', + created_at: '2015-01-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +const GH_EVENTS = [ + { + id: '1', + type: 'IssuesEvent', + created_at: new Date(Date.now() - 3 * 3_600_000).toISOString(), + repo: { id: 9, name: 'loopwright/agents', url: '' }, + payload: { + action: 'opened', + issue: { + title: 'Anyone know a good alternative to Stripe for cross-border payouts?', + body: 'Fees are brutal.', + html_url: 'https://github.com/loopwright/agents/issues/12', + }, + }, + }, +]; + +function stubGitHub(): GitHubProvider { + const fetchImpl = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input.toString(); + const body = url.includes('/events/public') + ? GH_EVENTS + : url.includes('/repos') + ? [] + : url.includes('/users/alexchen') + ? GH_PROFILE + : null; + + if (!body) return new Response('{"message":"Not Found"}', { status: 404 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + return new GitHubProvider({ fetchImpl }); +} + +async function withGitHub(label: string): Promise> { + const seeded = await seedDatabase(label); + active = seeded; + return createApp({ + db: seeded.db, + authenticate: async () => ACTOR, + github: stubGitHub(), + }); +} + +describe('adding a prospect (PRD §8)', () => { + test('a GitHub handle walks the chain and lands in the workspace', async () => { + const app = await withGitHub('prospect-add'); + const response = await post(app, '/prospects', { handle: 'alexchen' }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.added).toBe(true); + expect(body.personId).toBeTruthy(); + expect(body.stage).not.toBe('stopped'); + }); + + test('a pasted profile URL means the same thing as the handle', async () => { + const app = await withGitHub('prospect-url'); + const response = await post(app, '/prospects', { handle: 'https://github.com/alexchen' }); + + expect(response.status).toBe(200); + expect((await response.json()).added).toBe(true); + }); + + test('an @-prefixed handle is accepted rather than rejected as invalid', async () => { + const app = await withGitHub('prospect-at'); + expect((await post(app, '/prospects', { handle: '@alexchen' })).status).toBe(200); + }); + + test('a handle GitHub could never issue is refused before spending a call', async () => { + const app = await withGitHub('prospect-bad'); + expect((await post(app, '/prospects', { handle: 'not a username!' })).status).toBe(400); + }); + + test('an unknown profile reports why rather than failing the request', async () => { + const app = await withGitHub('prospect-missing'); + const response = await post(app, '/prospects', { handle: 'ghostuser' }); + + // A typo is information, not a server fault: 200 with a reason keeps the + // client from showing "something went wrong". + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.added).toBe(false); + expect(body.reason).toContain('ghostuser'); + }); + + test('adding a prospect is audited', async () => { + const app = await withGitHub('prospect-audit'); + await post(app, '/prospects', { handle: 'alexchen' }); + + const rows = await active!.db.execute( + "SELECT count(*) AS n FROM audit_events WHERE event_type = 'prospect.added'", + ); + expect(Number(rows.rows[0]?.n)).toBe(1); + }); + + test('a viewer cannot add prospects', async () => { + const seeded = await seedDatabase('prospect-viewer'); + active = seeded; + const app = createApp({ + db: seeded.db, + authenticate: async () => ({ ...ACTOR, role: 'viewer' }), + github: stubGitHub(), + }); + + expect((await post(app, '/prospects', { handle: 'alexchen' })).status).toBe(403); + }); + + test('a workspace with no campaign gets one rather than an error', async () => { + const app = await withGitHub('prospect-no-campaign'); + // Accounts created before registration provisioned a campaign have none. + await active!.db.execute('DELETE FROM recommendations'); + await active!.db.execute('DELETE FROM campaign_people'); + await active!.db.execute('DELETE FROM campaigns'); + + const response = await post(app, '/prospects', { handle: 'alexchen' }); + expect(response.status).toBe(200); + + const rows = await active!.db.execute('SELECT count(*) AS n FROM campaigns'); + expect(Number(rows.rows[0]?.n)).toBe(1); + }); +}); + +// ------------------------------------------------------------ verification + +/** Collects sent messages instead of delivering them. */ +function recordingMailer(): { sent: Message[]; mailer: Mailer } { + const sent: Message[] = []; + return { sent, mailer: { send: async (message) => void sent.push(message) } }; +} + +describe('email verification', () => { + test('registering mails a verification link', async () => { + const seeded = await seedDatabase('verify-register'); + active = seeded; + const { sent, mailer } = recordingMailer(); + const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' }); + + const response = await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + expect(response.status).toBe(201); + expect(sent).toHaveLength(1); + expect(sent[0]!.to).toBe('new@example.com'); + expect(sent[0]!.text).toContain('https://og.test/verify?token='); + }); + + test('a new account is unverified until the link is followed', async () => { + const seeded = await seedDatabase('verify-unverified'); + active = seeded; + const { mailer } = recordingMailer(); + const app = createApp({ db: seeded.db, mailer }); + + await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + const row = await seeded.db.execute({ + sql: 'SELECT email_verified_at FROM users WHERE email = ?', + args: ['new@example.com'], + }); + expect(row.rows[0]?.email_verified_at).toBeNull(); + }); + + test('following the link confirms the address', async () => { + const seeded = await seedDatabase('verify-confirm'); + active = seeded; + const { sent, mailer } = recordingMailer(); + const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' }); + + await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1]; + const response = await post(app, '/auth/verify', { token }); + + expect(response.status).toBe(200); + expect((await response.json()).verified).toBe(true); + }); + + test('a token cannot be used twice', async () => { + const seeded = await seedDatabase('verify-replay'); + active = seeded; + const { sent, mailer } = recordingMailer(); + const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' }); + + await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1]; + await post(app, '/auth/verify', { token }); + + expect((await post(app, '/auth/verify', { token })).status).toBe(400); + }); + + test('an expired token is refused', async () => { + const seeded = await seedDatabase('verify-expired'); + active = seeded; + const { sent, mailer } = recordingMailer(); + const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' }); + + await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + await seeded.db.execute({ + sql: 'UPDATE email_verification_tokens SET expires_at = ?', + args: ['2000-01-01T00:00:00.000Z'], + }); + + const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1]; + expect((await post(app, '/auth/verify', { token })).status).toBe(400); + }); + + test('a made-up token is refused', async () => { + const seeded = await seedDatabase('verify-forged'); + active = seeded; + const app = createApp({ db: seeded.db }); + + expect((await post(app, '/auth/verify', { token: 'deadbeef' })).status).toBe(400); + }); + + test('resending supersedes the previous link rather than stacking', async () => { + const seeded = await seedDatabase('verify-resend'); + active = seeded; + const { sent, mailer } = recordingMailer(); + + let userId = ''; + const app = createApp({ + db: seeded.db, + mailer, + appUrl: 'https://og.test', + authenticate: async () => (userId ? { ...ACTOR, userId } : undefined), + }); + + // Register through a second app instance so the guard above stays off + // until the account exists. + const open = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' }); + const registered = await ( + await post(open, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }) + ).json(); + userId = registered.userId; + + await post(app, '/auth/verify/resend'); + + const first = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1]; + const second = sent[1]!.text.match(/token=([a-f0-9]+)/)?.[1]; + expect(second).not.toBe(first); + + // The old link must stop working, or "resend" becomes a way to hold + // several simultaneously valid tokens. + expect((await post(open, '/auth/verify', { token: first })).status).toBe(400); + expect((await post(open, '/auth/verify', { token: second })).status).toBe(200); + }); + + test('an unverified account cannot approve outreach', async () => { + const seeded = await seedDatabase('verify-gate'); + active = seeded; + await seeded.db.execute({ + sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?', + args: [SEED.userId], + }); + + const app = createApp({ db: seeded.db, authenticate: async () => ACTOR }); + const response = await post(app, `/recommendations/${SEED.recommendationId}/approve`, {}); + + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe('email_unverified'); + }); + + test('an unverified account can still add prospects and read evidence', async () => { + const seeded = await seedDatabase('verify-gate-read'); + active = seeded; + await seeded.db.execute({ + sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?', + args: [SEED.userId], + }); + + const app = createApp({ + db: seeded.db, + authenticate: async () => ACTOR, + github: stubGitHub(), + }); + + // The gate is on sending, not on looking around. + expect((await get(app, '/people')).status).toBe(200); + expect((await post(app, '/prospects', { handle: 'alexchen' })).status).toBe(200); + }); + + test('a failed send does not fail the signup', async () => { + const seeded = await seedDatabase('verify-send-fails'); + active = seeded; + const app = createApp({ + db: seeded.db, + mailer: { + send: async () => { + throw new Error('resend is down'); + }, + }, + }); + + const response = await post(app, '/auth/register', { + email: 'new@example.com', + password: 'correct horse battery', + }); + + // Losing the account over a transient provider outage is worse than an + // account whose owner has to press "resend". + expect(response.status).toBe(201); + + const rows = await seeded.db.execute( + "SELECT count(*) AS n FROM audit_events WHERE event_type = 'email.send_failed'", + ); + expect(Number(rows.rows[0]?.n)).toBe(1); + }); + + test('/auth/me reports verification so the UI can warn before a refusal', async () => { + const { app } = await harness('verify-me'); + const body = await (await get(app, '/auth/me')).json(); + + expect(body.emailVerified).toBe(true); + }); +}); + +describe('listing prospects', () => { + test('the seeded prospect is listed with its score', async () => { + const { app } = await harness('people-list'); + const response = await get(app, '/people'); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.people.length).toBeGreaterThan(0); + expect(body.people[0].display_name).toBeTruthy(); + }); + + test('a deleted person is not listed', async () => { + const { app } = await harness('people-list-deleted'); + await active!.db.execute({ + sql: "UPDATE people SET status = 'deleted' WHERE id = ?", + args: [SEED.personId], + }); + + const body = await (await get(app, '/people')).json(); + expect(body.people).toHaveLength(0); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dff5e6e..5a15866 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { z } from 'zod'; import { + addProspectSchema, approveRecommendationSchema, createSuppressionSchema, executeActionSchema, @@ -24,16 +25,22 @@ import { now, queryOne, type Client } from '@outreachgraph/db'; import { actorFromSession, clearedCookie, + isEmailVerified, login, logout, + mintVerificationToken, readCookie, registerUser, SESSION_COOKIE, sessionCookie, + verifyEmailToken, workspacesForUser, } from './auth'; import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy'; import { draftForRecommendation, type TextModel } from '@outreachgraph/ai'; +import { runPipeline } from '@outreachgraph/pipeline'; +import { GitHubProvider } from '@outreachgraph/providers'; +import { ConsoleMailer, verificationEmail, type Mailer } from '@outreachgraph/email'; import { ApiError, canApprove, type AppEnv, type RequestActor } from './context'; import * as repo from './repository'; @@ -56,6 +63,18 @@ export interface AppOptions { * other route works unchanged and drafting returns 503. */ readonly model?: TextModel; + /** + * Supplies GitHub enrichment and activity when adding a prospect. Tests + * inject a fake; production leaves it unset and gets the real client. + */ + readonly github?: GitHubProvider; + /** + * Sends account email. Omit to log messages instead of sending them, which + * is what local development and the test suite do. + */ + readonly mailer?: Mailer; + /** Public origin, used to build links that land in someone's inbox. */ + readonly appUrl?: string; readonly version?: string; readonly commitHash?: string; } @@ -154,6 +173,31 @@ export function createApp(options: AppOptions): Hono { return undefined; }; + /** + * Mints a token and mails the link. + * + * A send failure never fails the request that triggered it: an account that + * exists and can sign in, with a resend button one tap away, beats a 500 + * that loses the password the user just chose. The failure is audited so it + * is visible rather than silent. + */ + const sendVerification = async (userId: string, email: string): Promise => { + const minted = await mintVerificationToken(options.db, userId, email); + const link = `${options.appUrl ?? 'http://localhost:8080'}/verify?token=${minted.token}`; + + try { + await (options.mailer ?? new ConsoleMailer()).send(verificationEmail(email, link)); + } catch (error) { + await repo.audit(options.db, { + actorKind: 'system', + eventType: 'email.send_failed', + entityKind: 'user', + entityId: userId, + detail: { kind: 'verification', message: String(error) }, + }); + } + }; + const api = new Hono(); // ------------------------------------------------------------------- auth @@ -165,13 +209,44 @@ export function createApp(options: AppOptions): Hono { const result = await registerUser(options.db, body); // Registering logs you straight in; a signup that then demands a login is - // just a worse signup. + // just a worse signup. Verification gates outbound actions, not access — + // someone should be able to look around while the mail is in flight. const session = await login(options.db, body.email, body.password, c.req.header('user-agent')); + await sendVerification(result.userId, body.email); c.header('set-cookie', sessionCookie(session.token, session.expiresAt, secure)); return c.json({ userId: result.userId, workspaceId: result.workspaceId }, 201); }); + /** + * Confirms an address from the emailed link. + * + * Unauthenticated on purpose: the link is often opened in a different + * browser from the one that signed up, and requiring a session there would + * strand people on a login screen holding a valid token. + */ + auth.post('/verify', async (c) => { + const body = safeJson(await c.req.raw.text()); + const result = await verifyEmailToken(options.db, String(body.token ?? '')); + return c.json({ verified: true, email: result.email }); + }); + + auth.post('/verify/resend', async (c) => { + const actor = await resolveActor(c.req.raw); + if (!actor) throw ApiError.unauthorized(); + + const user = await queryOne<{ email: string; email_verified_at: string | null }>( + options.db, + 'SELECT email, email_verified_at FROM users WHERE id = ?', + [actor.userId], + ); + if (!user) throw ApiError.unauthorized(); + if (user.email_verified_at) return c.json({ sent: false, reason: 'already_verified' }); + + await sendVerification(actor.userId, user.email); + return c.json({ sent: true }); + }); + auth.post('/login', async (c) => { const body = await parseBody(c.req.raw, loginSchema); const session = await login(options.db, body.email, body.password, c.req.header('user-agent')); @@ -192,14 +267,18 @@ export function createApp(options: AppOptions): Hono { const actor = await resolveActor(c.req.raw); if (!actor) throw ApiError.unauthorized(); - const user = await queryOne<{ id: string; email: string; name: string | null }>( - options.db, - 'SELECT id, email, name FROM users WHERE id = ?', - [actor.userId], - ); + const user = await queryOne<{ + id: string; + email: string; + name: string | null; + email_verified_at: string | null; + }>(options.db, 'SELECT id, email, name, email_verified_at FROM users WHERE id = ?', [ + actor.userId, + ]); return c.json({ user: user ?? { id: actor.userId, email: null, name: null }, + emailVerified: Boolean(user?.email_verified_at), workspaceId: actor.workspaceId, organizationId: actor.organizationId, role: actor.role, @@ -250,7 +329,101 @@ export function createApp(options: AppOptions): Hono { return c.json({ recommendations: rows.rows }); }); + // ----------------------------------------------------------- prospects + /** + * Adds one prospect by GitHub handle and runs the full chain (PRD §8). + * + * Synchronous on purpose: a first-run user needs to see something appear, + * and a background job that silently produces nothing is exactly the empty + * app this replaces. The chain is a handful of GitHub calls, so it returns + * in seconds. When that stops being true, this becomes a queued job and the + * response becomes a job id — the route shape already allows it. + */ + api.post('/prospects', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + + if (!canApprove(actor)) throw ApiError.forbidden('adding prospects'); + + const raw = safeJson(await c.req.raw.text()); + const parsed = addProspectSchema.safeParse({ handle: normalizeHandle(raw.handle) }); + if (!parsed.success) { + throw ApiError.badRequest( + 'enter a GitHub username or profile URL', + parsed.error.flatten().fieldErrors, + ); + } + + const handle = parsed.data.handle; + const campaignId = await ensureDefaultCampaign(db, actor.workspaceId); + + const result = await runPipeline( + { + db, + workspaceId: actor.workspaceId, + campaignId, + providers: [], + github: options.github ?? new GitHubProvider(), + ...(options.model ? { model: options.model } : {}), + }, + handle, + ); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'prospect.added', + entityKind: 'person', + entityId: result.personId ?? handle, + detail: { handle, stage: result.stage, stoppedBecause: result.stoppedBecause }, + }); + + // A stopped chain is a real answer, not an error: "no GitHub profile for + // that handle" is information the user needs, and 200 with a reason keeps + // the client from treating a typo as a server fault. + return c.json({ + added: result.stage !== 'stopped', + personId: result.personId, + stage: result.stage, + identitiesLinked: result.identitiesLinked, + signalsStored: result.signalsStored, + recommendationId: result.recommendationId, + ...(result.stoppedBecause ? { reason: result.stoppedBecause } : {}), + }); + }); + // -------------------------------------------------------------- people + /** + * The prospect list, ranked the way the UI ranks everything else — by + * opportunity, so the top of the list is the top of the queue. + */ + api.get('/people', async (c) => { + const actor = c.get('actor'); + const limit = clampLimit(c.req.query('limit')); + + const rows = await c.get('db').execute({ + sql: `SELECT p.id, p.display_name, p.current_title, p.identity_confidence, p.status, + co.name AS current_company, + cp.status AS prospect_status, cp.interaction_state, + s.opportunity, s.icp_fit, s.intent, s.reachability, + (SELECT COUNT(*) FROM signals g + WHERE g.person_id = p.id AND g.workspace_id = cp.workspace_id) + AS signal_count + FROM campaign_people cp + JOIN people p ON p.id = cp.person_id + LEFT JOIN companies co ON co.id = p.current_company_id + LEFT JOIN scores s + ON s.person_id = cp.person_id AND s.campaign_id = cp.campaign_id + WHERE cp.workspace_id = ? AND p.status != 'deleted' + ORDER BY COALESCE(s.opportunity, -1) DESC, p.display_name ASC + LIMIT ?`, + args: [actor.workspaceId, limit], + }); + + return c.json({ people: rows.rows }); + }); + api.get('/people/:id', async (c) => { const actor = c.get('actor'); const db = c.get('db'); @@ -362,6 +535,7 @@ export function createApp(options: AppOptions): Hono { const db = c.get('db'); if (!canApprove(actor)) throw ApiError.forbidden('this role cannot approve outbound actions'); + await requireVerifiedEmail(db, actor); const body = await parseBody(c.req.raw, approveRecommendationSchema); const recommendation = await repo.getRecommendation(db, actor.workspaceId, c.req.param('id')); @@ -583,6 +757,8 @@ export function createApp(options: AppOptions): Hono { const db = c.get('db'); const body = await parseBody(c.req.raw, executeActionSchema); + await requireVerifiedEmail(db, actor); + const action = await repo.getAction(db, actor.workspaceId, c.req.param('id')); if (!action) throw ApiError.notFound('action'); if (action.status === 'completed') throw ApiError.badRequest('action is already completed'); @@ -845,6 +1021,90 @@ function numberOr(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } +/** + * Refuses outbound work from an unconfirmed address. + * + * The gate is on sending, not on signing in: someone should be able to add + * prospects and read evidence while the mail is in flight. It sits here + * rather than in the policy engine deliberately — the engine decides what a + * network permits, and account state is not a policy question. + * + * A service token has no user, so this cannot apply to it; machine callers + * are authorised by holding the token. + */ +async function requireVerifiedEmail(db: Client, actor: RequestActor): Promise { + if (actor.userId === 'usr_service') return; + if (await isEmailVerified(db, actor.userId)) return; + + throw new ApiError(403, 'email_unverified', 'confirm your email address before sending anything'); +} + +/** + * Accepts what people actually paste. + * + * A profile URL, an `@handle` and a bare username all mean the same thing to + * the person typing, so all three are normalised to the handle rather than + * rejected as invalid input. + */ +function normalizeHandle(value: unknown): string { + if (typeof value !== 'string') return ''; + + const trimmed = value.trim(); + const url = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/?#]+)/i); + return (url?.[1] ?? trimmed).replace(/^@/, '').replace(/\/+$/, ''); +} + +/** + * Returns the workspace's campaign, provisioning one if it has none. + * + * Registration now creates an offering and campaign up front, but accounts + * made before that have neither, and an existing user hitting "add prospect" + * must not get an error telling them to create something the UI does not yet + * expose. Backfilling here keeps the failure mode out of the product. + */ +async function ensureDefaultCampaign(db: Client, workspaceId: string): Promise { + const existing = await queryOne<{ id: string }>( + db, + `SELECT id FROM campaigns WHERE workspace_id = ? + ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, created_at ASC LIMIT 1`, + [workspaceId], + ); + if (existing) return existing.id; + + const stamp = now(); + const offering = await queryOne<{ id: string }>( + db, + 'SELECT id FROM offerings WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1', + [workspaceId], + ); + + let offeringId = offering?.id; + if (!offeringId) { + offeringId = newId('offering'); + await db.execute({ + sql: `INSERT INTO offerings (id, workspace_id, name, category, description, created_at, updated_at) + VALUES (?, ?, 'Your offering', 'unspecified', ?, ?, ?)`, + args: [ + offeringId, + workspaceId, + 'Describe what you sell here. Every draft is grounded in this text.', + stamp, + stamp, + ], + }); + } + + const campaignId = newId('campaign'); + await db.execute({ + sql: `INSERT INTO campaigns (id, workspace_id, name, offering_id, approval_mode, status, + created_at, updated_at, started_at) + VALUES (?, ?, 'First campaign', ?, 'draft_and_approve', 'active', ?, ?, ?)`, + args: [campaignId, workspaceId, offeringId, stamp, stamp, stamp], + }); + + return campaignId; +} + /** * Constant-time string comparison, so a service token cannot be recovered by * timing how long a rejection takes. diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index f53da79..dd618cc 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -88,13 +88,20 @@ export interface RegisterResult { readonly userId: string; readonly organizationId: string; readonly workspaceId: string; + readonly offeringId: string; + readonly campaignId: string; } /** - * Creates a user with their own organization and first workspace. + * Creates a user with their own organization, workspace, offering and campaign. * * A user with no workspace cannot do anything in this product, so registration - * provisions one rather than leaving a half-created account. + * provisions one rather than leaving a half-created account. The offering and + * campaign are provisioned for the same reason: a campaign requires an + * offering, and a prospect requires a campaign, so an account without both is + * one where "add a prospect" has nowhere to write. They are placeholders the + * user is expected to edit — the composer grounds every draft in the offering, + * so leaving it unedited produces weak drafts, not wrong ones. */ export async function registerUser(db: Client, input: RegisterInput): Promise { const email = normalizeEmail(input.email); @@ -111,6 +118,8 @@ export async function registerUser(db: Client, input: RegisterInput): Promise { + const token = mintSessionToken(); + const stamp = now(); + const expiresAt = new Date( + new Date(stamp).getTime() + VERIFICATION_TTL_HOURS * 3_600_000, + ).toISOString(); + + await db.batch([ + { sql: 'DELETE FROM email_verification_tokens WHERE user_id = ?', args: [userId] }, + { + sql: `INSERT INTO email_verification_tokens (id, user_id, token_hash, email, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [newId('session'), userId, await hashToken(token), email, stamp, expiresAt], + }, + ]); + + return { token, email, expiresAt }; +} + +export interface VerificationResult { + readonly userId: string; + readonly email: string; +} + +/** + * Consumes a verification token and marks the address confirmed. + * + * Every failure — unknown, expired, already used — answers the same way. A + * verification endpoint that distinguishes them tells an attacker holding a + * guessed token whether it ever existed. + */ +export async function verifyEmailToken(db: Client, token: string): Promise { + const invalid = new ApiError(400, 'invalid_token', 'that link is invalid or has expired'); + if (!token) throw invalid; + + const row = await queryOne<{ + id: string; + user_id: string; + email: string; + expires_at: string; + consumed_at: string | null; + }>(db, 'SELECT * FROM email_verification_tokens WHERE token_hash = ?', [await hashToken(token)]); + + if (!row || row.consumed_at) throw invalid; + + const stamp = now(); + if (new Date(row.expires_at).getTime() <= new Date(stamp).getTime()) throw invalid; + + // The address may have changed since the token was minted; verifying the + // old one would confirm something the user no longer uses. + const user = await queryOne<{ email: string }>(db, 'SELECT email FROM users WHERE id = ?', [ + row.user_id, + ]); + if (!user || user.email !== row.email) throw invalid; + + await db.batch([ + { + sql: 'UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ?', + args: [stamp, row.id], + }, + { + sql: 'UPDATE users SET email_verified_at = ?, updated_at = ? WHERE id = ?', + args: [stamp, stamp, row.user_id], + }, + ]); + + return { userId: row.user_id, email: row.email }; +} + +export async function isEmailVerified(db: Client, userId: string): Promise { + const row = await queryOne<{ email_verified_at: string | null }>( + db, + 'SELECT email_verified_at FROM users WHERE id = ?', + [userId], + ); + return Boolean(row?.email_verified_at); +} + /** Cookie attributes. `secure` is dropped only for plain-HTTP local dev. */ export function sessionCookie(token: string, expiresAt: string, secure: boolean): string { const parts = [ diff --git a/apps/api/src/test-seed.ts b/apps/api/src/test-seed.ts index f8292e6..2782453 100644 --- a/apps/api/src/test-seed.ts +++ b/apps/api/src/test-seed.ts @@ -45,9 +45,11 @@ export async function seedDatabase(label: string): Promise { args: [SEED.organizationId, stamp, stamp], }, { - sql: `INSERT INTO users (id, email, name, created_at, updated_at) - VALUES (?, 'test@example.com', 'Test User', ?, ?)`, - args: [SEED.userId, stamp, stamp], + // Verified, because the fixture stands for an established account. + // Tests that care about the unverified path clear this explicitly. + sql: `INSERT INTO users (id, email, name, email_verified_at, created_at, updated_at) + VALUES (?, 'test@example.com', 'Test User', ?, ?, ?)`, + args: [SEED.userId, stamp, stamp, stamp], }, { sql: `INSERT INTO workspaces (id, organization_id, name, slug, min_outreach_confidence, @@ -85,6 +87,14 @@ export async function seedDatabase(label: string): Promise { 'San Francisco Bay Area', 0.97, 'active', 1, 0, ?, ?)`, args: [SEED.personId, SEED.companyId, stamp, stamp], }, + { + // The pipeline always writes this row, so a fixture without one is a + // workspace no real code path can produce. + sql: `INSERT INTO campaign_people (campaign_id, person_id, workspace_id, status, + interaction_state, discovered_at, updated_at) + VALUES (?, ?, ?, 'recommended', 'never_contacted', ?, ?)`, + args: [SEED.campaignId, SEED.personId, SEED.workspaceId, stamp, stamp], + }, { sql: `INSERT INTO social_identities (id, person_id, network, handle, platform_user_id, confidence, source_type, verified_by, first_seen_at) diff --git a/apps/server/package.json b/apps/server/package.json index 48d15bc..e4e8dc2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,8 @@ "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", "@outreachgraph/domain": "workspace:*", + "@outreachgraph/email": "workspace:*", + "@outreachgraph/pipeline": "workspace:*", "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", "@outreachgraph/recommend": "workspace:*", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8dc38ec..4d4dc7c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -21,9 +21,10 @@ import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db'; import { ClaudeModel } from '@outreachgraph/ai'; +import { ResendMailer } from '@outreachgraph/email'; import { createApp } from '../../api/src/app'; import { pruneSessions } from '../../api/src/auth'; -import { expireSignals, processDeletion } from '../../worker/src/jobs'; +import { expireSignals, processDeletion } from '@outreachgraph/pipeline'; const PORT = Number(process.env.PORT ?? 8080); const WEB_PORT = Number(process.env.WEB_PORT ?? 3001); @@ -78,10 +79,29 @@ const model = process.env.ANTHROPIC_API_KEY if (!model) console.log('no ANTHROPIC_API_KEY: drafting disabled, queue still runs'); +/** + * Account email. + * + * Same shape as the model above: absent credentials degrade to logging rather + * than to a refusal to boot. A verification link printed in the container log + * is recoverable; a container that will not start is not. + */ +const mailer = + process.env.RESEND_API_KEY && process.env.EMAIL_FROM + ? new ResendMailer({ + apiKey: process.env.RESEND_API_KEY, + from: process.env.EMAIL_FROM, + }) + : undefined; + +if (!mailer) console.log('no RESEND_API_KEY/EMAIL_FROM: verification links are logged, not sent'); + // ---------------------------------------------------------------------- api const api = createApp({ db, ...(model ? { model } : {}), + ...(mailer ? { mailer } : {}), + ...(process.env.APP_URL ? { appUrl: process.env.APP_URL } : {}), ...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}), // Cookies must not be Secure over plain HTTP, or local development can // never hold a session. diff --git a/apps/web/app/prospects/[id]/page.tsx b/apps/web/app/prospects/[id]/page.tsx new file mode 100644 index 0000000..1077c77 --- /dev/null +++ b/apps/web/app/prospects/[id]/page.tsx @@ -0,0 +1,122 @@ +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; +import { + ApiUnavailableError, + NotAuthenticatedError, + fetchProspect, + relativeTime, +} from '../../../lib/api'; +import type { ProspectDetail } from '../../../lib/types'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { title: 'Prospect · OutreachGraph' }; + +/** + * One prospect: who we think they are, and what we can prove (PRD §25.3). + * + * Identities carry their confidence and signals carry their source link, + * because the product's claim is not "we found this person" but "here is why + * we believe it" — a reviewer who cannot check the evidence cannot approve + * anything responsibly. + */ +export default async function ProspectPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + let detail: ProspectDetail; + + try { + detail = await fetchProspect(id); + } catch (error) { + if (error instanceof NotAuthenticatedError) redirect('/login'); + if (error instanceof ApiUnavailableError) { + return ( +

+ The API is not reachable right now. +

+ ); + } + notFound(); + } + + const { person, identities, signals } = detail; + + return ( +
+ + ← Prospects + + +
+

{person.display_name}

+

{person.current_title ?? '—'}

+

+ Identity confidence {Math.round((person.identity_confidence ?? 0) * 100)}% +

+
+ +
+

+ Identities +

+ {identities.length ? ( +
    + {identities.map((identity) => ( +
  • +
    +
    {identity.handle}
    +
    {identity.network}
    +
    + + {identity.confidence.toFixed(2)} + +
  • + ))} +
+ ) : ( +

No linked identities.

+ )} +
+ +
+

+ Signals +

+ {signals.length ? ( +
    + {signals.map((signal) => ( +
  • +

    {signal.summary}

    +

    + {signal.signal_type.replace(/_/g, ' ')} · {relativeTime(signal.source_timestamp)} + {signal.source_url ? ( + <> + {' · '} + + source + + + ) : null} +

    +
  • + ))} +
+ ) : ( +

+ No signals captured yet. Nothing personalised can be written without them. +

+ )} +
+
+ ); +} diff --git a/apps/web/app/prospects/page.tsx b/apps/web/app/prospects/page.tsx index dd691f5..319370f 100644 --- a/apps/web/app/prospects/page.tsx +++ b/apps/web/app/prospects/page.tsx @@ -1,17 +1,94 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { AddProspect } from '../../components/add-prospect'; +import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../lib/api'; +import type { ProspectRow } from '../../lib/types'; + +export const dynamic = 'force-dynamic'; + export const metadata = { title: 'Prospects · OutreachGraph' }; -export default function ProspectsPage() { +/** + * Prospects — everyone in the workspace, ranked by opportunity (PRD §25.2). + * + * The add form sits above the list rather than behind a button because on a + * new account this page is the only thing standing between an empty product + * and a working one. + */ +export default async function ProspectsPage() { + let people: ProspectRow[] = []; + let offline = false; + + try { + people = await fetchProspects(); + } catch (error) { + if (error instanceof NotAuthenticatedError) redirect('/login'); + if (error instanceof ApiUnavailableError) offline = true; + else throw error; + } + return (

Prospects

-

Ranked by opportunity

+

+ {offline ? 'Waiting for the API' : `${people.length} in this workspace`} +

-

- Prospect search is not built yet. The API exposes /api/v1/people/:id with - identities, signals and provenance. -

+ + + {people.length > 0 ? ( +
    + {people.map((person) => ( + + ))} +
+ ) : !offline ? ( +

+ No prospects yet. Add a GitHub handle above and the pipeline will enrich, resolve, collect + signals and score them. +

+ ) : null}
); } + +function ProspectItem({ person }: { person: ProspectRow }) { + const subtitle = [person.current_title, person.current_company].filter(Boolean).join(' · '); + + return ( +
  • + +
    + {person.display_name} + + {person.opportunity ?? '—'} + +
    + +

    {subtitle || '—'}

    + +
    +
    +
    Signals
    +
    {person.signal_count}
    +
    +
    +
    Identity
    +
    + {Math.round((person.identity_confidence ?? 0) * 100)}% +
    +
    +
    +
    Status
    +
    {person.prospect_status.replace(/_/g, ' ')}
    +
    +
    + +
  • + ); +} diff --git a/apps/web/app/today/page.tsx b/apps/web/app/today/page.tsx index 4310175..303f983 100644 --- a/apps/web/app/today/page.tsx +++ b/apps/web/app/today/page.tsx @@ -1,9 +1,11 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { VerifyBanner } from '../../components/verify-banner'; import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals, + fetchMe, fetchSignals, relativeTime, } from '../../lib/api'; @@ -21,10 +23,11 @@ export const metadata = { title: 'Today · OutreachGraph' }; export default async function TodayPage() { let approvals: Awaited> = []; let signals: Awaited> = []; + let me: Awaited> | undefined; let offline = false; try { - [approvals, signals] = await Promise.all([fetchApprovals(), fetchSignals()]); + [approvals, signals, me] = await Promise.all([fetchApprovals(), fetchSignals(), fetchMe()]); } catch (error) { if (error instanceof NotAuthenticatedError) redirect('/login'); if (error instanceof ApiUnavailableError) offline = true; @@ -35,6 +38,8 @@ export default async function TodayPage() { return (
    + {me && !me.emailVerified ? : null} +

    Today

    @@ -81,10 +86,28 @@ export default async function TodayPage() { ) : null} + {/* + An empty queue has two very different causes, and telling them apart + is the difference between a working product and a broken-looking one. + With no prospects at all there is nothing for the pipeline to act on, + so the only useful thing to say is "add one" — the old copy promised + signals that could never arrive. + */} {!offline && approvals.length === 0 ? ( -

    - Nothing waiting. Recommendations appear as fresh signals arrive. -

    +
    +

    Nothing waiting.

    +

    + {signals.length === 0 + ? 'Add a prospect and the pipeline will research them, collect public signals and score the opportunity.' + : 'Signals are arriving; recommendations appear when one is worth acting on.'} +

    + + Add a prospect + +
    ) : null}
    ); diff --git a/apps/web/app/verify/page.tsx b/apps/web/app/verify/page.tsx new file mode 100644 index 0000000..93d795e --- /dev/null +++ b/apps/web/app/verify/page.tsx @@ -0,0 +1,27 @@ +import { VerifyForm } from '../../components/verify-form'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { title: 'Confirm your email · OutreachGraph' }; + +/** + * The landing page for the emailed verification link. + * + * The token arrives in the query string and is posted from the browser rather + * than consumed here, so a mail client or scanner that prefetches the link + * cannot silently burn a single-use token before the person ever clicks it. + */ +export default async function VerifyPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = await searchParams; + + return ( +
    +

    Confirm your email

    + +
    + ); +} diff --git a/apps/web/components/add-prospect.tsx b/apps/web/components/add-prospect.tsx new file mode 100644 index 0000000..8fb506d --- /dev/null +++ b/apps/web/components/add-prospect.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, type FormEvent } from 'react'; + +/** + * Add one prospect by GitHub handle (PRD §8). + * + * The chain runs synchronously, so this reports what actually happened rather + * than "queued": how many identities were linked and how many signals were + * found is the difference between a prospect worth pursuing and a dead end, + * and hiding it behind a spinner that resolves to nothing was the old + * behaviour of an empty app. + * + * A handle that resolves to no profile comes back 200 with a reason. That is + * a typo, not a server fault, and it is shown as a note rather than an error. + */ +export function AddProspect() { + const router = useRouter(); + const [handle, setHandle] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [note, setNote] = useState(); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!handle.trim()) return; + + setBusy(true); + setError(undefined); + setNote(undefined); + + try { + const response = await fetch('/api/v1/prospects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ handle }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + setError(payload?.error?.message ?? `that failed (${response.status})`); + return; + } + + if (!payload.added) { + setNote(payload.reason ?? 'nothing to add for that handle'); + return; + } + + const found = + payload.signalsStored > 0 + ? `${payload.signalsStored} signal${payload.signalsStored === 1 ? '' : 's'} found` + : 'no public signals yet'; + + setNote(`Added. ${payload.identitiesLinked} identities linked, ${found}.`); + setHandle(''); + router.refresh(); + } catch { + setError('could not reach the server'); + } finally { + setBusy(false); + } + } + + return ( +
    + +

    + A GitHub username or profile URL. GitHub first because it is free and its profiles carry + links the person published themselves. +

    + +
    + setHandle(e.target.value)} + placeholder="octocat" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + className="border-border bg-surface min-w-0 flex-1 rounded-xl border px-3 py-3" + /> + +
    + + {busy ? ( +

    + Enriching, resolving identities, collecting signals and scoring — a few seconds. +

    + ) : null} + + {note ?

    {note}

    : null} + + {error ? ( +

    + {error} +

    + ) : null} +
    + ); +} diff --git a/apps/web/components/verify-banner.tsx b/apps/web/components/verify-banner.tsx new file mode 100644 index 0000000..2256ab2 --- /dev/null +++ b/apps/web/components/verify-banner.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; + +/** + * Tells an unverified account why approving will fail, before they try. + * + * Discovering a gate by being refused mid-task is the worst way to learn + * about it, so this states the limit up front and offers the one action that + * clears it. + */ +export function VerifyBanner({ email }: { email: string | null }) { + const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'failed'>('idle'); + + async function resend() { + setState('sending'); + try { + const response = await fetch('/api/v1/auth/verify/resend', { + method: 'POST', + credentials: 'same-origin', + }); + setState(response.ok ? 'sent' : 'failed'); + } catch { + setState('failed'); + } + } + + return ( +
    +

    Confirm your email to send anything.

    +

    + Research, signals and drafts all work now. Approving outreach needs a confirmed address + {email ? ` — we sent a link to ${email}` : ''}. +

    + + {state === 'sent' ? ( +

    Sent. The newest link is the one that works.

    + ) : ( + + )} + + {state === 'failed' ? ( +

    + Could not send it. Try again in a moment. +

    + ) : null} +
    + ); +} diff --git a/apps/web/components/verify-form.tsx b/apps/web/components/verify-form.tsx new file mode 100644 index 0000000..0d5dbb5 --- /dev/null +++ b/apps/web/components/verify-form.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +type State = 'idle' | 'working' | 'done' | 'failed'; + +/** + * Confirms the address, then gets out of the way. + * + * The POST happens on mount rather than behind a button: the person already + * expressed intent by clicking the link in their inbox, and asking them to + * click a second time to do the thing they just asked for is friction with no + * security value. Prefetch protection comes from this being a POST at all. + */ +export function VerifyForm({ token }: { token: string }) { + const [state, setState] = useState('idle'); + const [message, setMessage] = useState(); + + useEffect(() => { + if (!token) { + setState('failed'); + setMessage('That link is missing its token. Try the most recent email.'); + return; + } + + let cancelled = false; + setState('working'); + + (async () => { + try { + const response = await fetch('/api/v1/auth/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ token }), + }); + + if (cancelled) return; + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + setState('failed'); + setMessage(body?.error?.message ?? 'that link is invalid or has expired'); + return; + } + + setState('done'); + } catch { + if (!cancelled) { + setState('failed'); + setMessage('could not reach the server'); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [token]); + + if (state === 'done') { + return ( +
    +

    Confirmed.

    +

    + Your address is verified and outreach is unlocked. +

    + + Go to Today + +
    + ); + } + + if (state === 'failed') { + return ( +
    +

    + {message} +

    +

    + Sign in and use “Resend” to get a fresh link — each one supersedes the last. +

    + + Sign in + +
    + ); + } + + return

    Confirming…

    ; +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 96a9773..8e873c1 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -9,9 +9,16 @@ */ import { cookies } from 'next/headers'; -import type { ApprovalCard, CurrentUser, SignalRow } from './types'; +import type { ApprovalCard, CurrentUser, ProspectDetail, ProspectRow, SignalRow } from './types'; -export type { ApprovalCard, CurrentUser, SignalRow } from './types'; +export type { + ApprovalCard, + CurrentUser, + IdentityRow, + ProspectDetail, + ProspectRow, + SignalRow, +} from './types'; export { relativeTime } from './format'; /** @@ -94,3 +101,12 @@ export async function fetchSignals(): Promise { const body = await request<{ signals: SignalRow[] }>('/signals?limit=50'); return body.signals; } + +export async function fetchProspects(): Promise { + const body = await request<{ people: ProspectRow[] }>('/people?limit=100'); + return body.people; +} + +export async function fetchProspect(id: string): Promise { + return request(`/people/${encodeURIComponent(id)}`); +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index a08c77c..5bb4d74 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -36,8 +36,44 @@ export interface SignalRow { relevance: number; } +export interface ProspectRow { + id: string; + display_name: string; + current_title: string | null; + current_company: string | null; + identity_confidence: number; + prospect_status: string; + interaction_state: string; + opportunity: number | null; + icp_fit: number | null; + intent: number | null; + reachability: number | null; + signal_count: number; +} + +export interface IdentityRow { + id: string; + network: string; + handle: string; + confidence: number; + source_type: string | null; +} + +export interface ProspectDetail { + person: { + id: string; + display_name: string; + current_title: string | null; + identity_confidence: number; + status: string; + }; + identities: IdentityRow[]; + signals: SignalRow[]; +} + export interface CurrentUser { user: { id: string; email: string | null; name: string | null }; + emailVerified: boolean; workspaceId: string; role: string; } diff --git a/bun.lock b/bun.lock index 2a4131b..d27f5f1 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,8 @@ "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", "@outreachgraph/domain": "workspace:*", + "@outreachgraph/email": "workspace:*", + "@outreachgraph/pipeline": "workspace:*", "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", "@outreachgraph/scoring": "workspace:*", @@ -34,6 +36,8 @@ "@outreachgraph/contracts": "workspace:*", "@outreachgraph/db": "workspace:*", "@outreachgraph/domain": "workspace:*", + "@outreachgraph/email": "workspace:*", + "@outreachgraph/pipeline": "workspace:*", "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", "@outreachgraph/recommend": "workspace:*", @@ -61,21 +65,6 @@ "tailwindcss": "^4.0.0", }, }, - "apps/worker": { - "name": "@outreachgraph/worker", - "version": "0.1.0", - "dependencies": { - "@outreachgraph/ai": "workspace:*", - "@outreachgraph/db": "workspace:*", - "@outreachgraph/domain": "workspace:*", - "@outreachgraph/identity": "workspace:*", - "@outreachgraph/policy": "workspace:*", - "@outreachgraph/providers": "workspace:*", - "@outreachgraph/recommend": "workspace:*", - "@outreachgraph/scoring": "workspace:*", - "@outreachgraph/signals": "workspace:*", - }, - }, "packages/ai": { "name": "@outreachgraph/ai", "version": "0.1.0", @@ -109,6 +98,10 @@ "name": "@outreachgraph/domain", "version": "0.1.0", }, + "packages/email": { + "name": "@outreachgraph/email", + "version": "0.1.0", + }, "packages/identity": { "name": "@outreachgraph/identity", "version": "0.1.0", @@ -116,6 +109,20 @@ "@outreachgraph/domain": "workspace:*", }, }, + "packages/pipeline": { + "name": "@outreachgraph/pipeline", + "version": "0.1.0", + "dependencies": { + "@outreachgraph/ai": "workspace:*", + "@outreachgraph/db": "workspace:*", + "@outreachgraph/domain": "workspace:*", + "@outreachgraph/identity": "workspace:*", + "@outreachgraph/providers": "workspace:*", + "@outreachgraph/recommend": "workspace:*", + "@outreachgraph/scoring": "workspace:*", + "@outreachgraph/signals": "workspace:*", + }, + }, "packages/policy": { "name": "@outreachgraph/policy", "version": "0.1.0", @@ -287,8 +294,12 @@ "@outreachgraph/domain": ["@outreachgraph/domain@workspace:packages/domain"], + "@outreachgraph/email": ["@outreachgraph/email@workspace:packages/email"], + "@outreachgraph/identity": ["@outreachgraph/identity@workspace:packages/identity"], + "@outreachgraph/pipeline": ["@outreachgraph/pipeline@workspace:packages/pipeline"], + "@outreachgraph/policy": ["@outreachgraph/policy@workspace:packages/policy"], "@outreachgraph/providers": ["@outreachgraph/providers@workspace:packages/providers"], @@ -303,8 +314,6 @@ "@outreachgraph/web": ["@outreachgraph/web@workspace:apps/web"], - "@outreachgraph/worker": ["@outreachgraph/worker@workspace:apps/worker"], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md index 8923cf8..3a50ff5 100644 --- a/docs/prd-implementation-map.md +++ b/docs/prd-implementation-map.md @@ -31,29 +31,31 @@ Where each part of the V1 PRD lives. Code comments cite section numbers | §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` | | §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — | | §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests | -| §17.6 Source deletion | `apps/worker/src/jobs.ts` `markSourceUnavailable` | worker tests | +| §17.6 Source deletion | `packages/pipeline/src/jobs.ts` `markSourceUnavailable` | pipeline tests | | §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests | | §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests | -| §21 Database model | `migrations/0000`–`0004` | `packages/db/src/migrate.test.ts` | -| §23 API endpoints | `apps/api/src/app.ts` | 31 tests | +| §21 Database model | `migrations/0000`–`0006` | `packages/db/src/migrate.test.ts` | +| §23 API endpoints | `apps/api/src/app.ts` | 61 tests | | §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` | | §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests | | §13 Next-best-action | `packages/recommend/src/engine.ts` | 25 tests | | §20.6 Strategy agent | `packages/recommend` — deterministic, chooses only from `allowedActions` | included above | | §16.6 GitHub as a signal source | `packages/providers/src/github/` | 27 tests | -| §8 Pipeline, end to end | `apps/worker/src/pipeline.ts` | 11 tests + live GitHub run | +| §8 Pipeline, end to end | `packages/pipeline/src/pipeline.ts`, `POST /prospects` | 11 + 8 tests, live GitHub run | +| §25.2 Prospect list and detail | `apps/web/app/prospects/`, `GET /people` | 2 API tests + build | +| §34 Email verification | `packages/email/`, `apps/api/src/auth.ts`, migration `0006` | 7 + 11 tests | ## Partially implemented -| PRD section | State | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| §7 Wizard | Domain types and contracts exist (`packages/domain/src/campaign.ts`, `packages/contracts`). No wizard UI or ICP agent. | -| §12.5 Relationship score | Scoring function exists; nothing populates its inputs yet. | -| §22 Person model | Schema complete. No ORM layer beyond `packages/db` helpers. | -| §27 Conversations | Interaction states and rows exist; no inbound ingestion. | -| §30 Billing | `usage_events` and `billing_accounts` tables; no metering enforcement or payment provider. | -| §1.1 PWA | `apps/web` — installable manifest, service worker, offline fallback, update prompt, safe-area layout, bottom nav, approval card, signal feed. Prospects and campaign screens are placeholders. No push notifications; icons are SVG only, so raster icons are still needed for older Android. No Lighthouse gate in CI. | -| §25.1–25.3 UI | Today, Signals and Approvals render live API data. The §25.2 prospect page is not built. | +| PRD section | State | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| §7 Wizard | Domain types and contracts exist (`packages/domain/src/campaign.ts`, `packages/contracts`). No wizard UI or ICP agent. | +| §12.5 Relationship score | Scoring function exists; nothing populates its inputs yet. | +| §22 Person model | Schema complete. No ORM layer beyond `packages/db` helpers. | +| §27 Conversations | Interaction states and rows exist; no inbound ingestion. | +| §30 Billing | `usage_events` and `billing_accounts` tables; no metering enforcement or payment provider. | +| §1.1 PWA | `apps/web` — installable manifest, service worker, offline fallback, update prompt, safe-area layout, bottom nav, approval card, signal feed, prospect list and detail, add-prospect flow, email verification. Campaign screens are still placeholders. No push notifications; icons are SVG only, so raster icons are still needed for older Android. No Lighthouse gate in CI. | +| §25.1–25.3 UI | Today, Signals, Approvals, Prospects and prospect detail all render live API data. Offering and voice editing are not built, so drafts are grounded in a placeholder offering until a user edits it in the database. | ## Not started diff --git a/migrations/0006_email_verification.sql b/migrations/0006_email_verification.sql new file mode 100644 index 0000000..af72269 --- /dev/null +++ b/migrations/0006_email_verification.sql @@ -0,0 +1,30 @@ +-- Email verification (PRD §34 account security). +-- +-- An unverified address is not just a typo risk: it is how someone signs up +-- as a person who never consented, and how a bounced address quietly costs +-- sender reputation. Outbound actions are gated on it, so verification is a +-- product control rather than a formality. + +ALTER TABLE users ADD COLUMN email_verified_at TEXT; + +-- Accounts that existed before verification shipped are grandfathered. They +-- were created by a human who was already using the product, and locking +-- them out of their own workspace to prove an address they already receive +-- mail at would be a regression, not a security gain. +UPDATE users SET email_verified_at = created_at WHERE email_verified_at IS NULL; + +-- Tokens are stored as a SHA-256 digest for the same reason sessions are: a +-- leaked database must not hand over a working verification link. +CREATE TABLE email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + -- The address the token was minted for. Kept so a token issued before an + -- email change cannot verify the new address. + email TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE INDEX idx_email_verification_user ON email_verification_tokens(user_id); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 536f08a..fd82d70 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -91,6 +91,26 @@ export const loginSchema = z.object({ export type RegisterInput = z.infer; export type LoginInput = z.infer; +/** + * Adding a prospect by GitHub handle. + * + * The pattern is GitHub's own rule — alphanumerics and single hyphens, never + * leading or trailing, 39 characters max — so a typo is rejected here rather + * than spending an API call to be told the profile does not exist. A pasted + * profile URL is a common enough input that the API strips it before + * validating; this schema sees only the handle. + */ +export const addProspectSchema = z.object({ + handle: z + .string() + .trim() + .min(1) + .max(39) + .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/, 'not a valid GitHub username'), +}); + +export type AddProspectInput = z.infer; + export const createOfferingSchema = z.object({ name: z.string().min(1).max(200), category: z.string().min(1).max(200), diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 0000000..b89b128 --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,14 @@ +{ + "name": "@outreachgraph/email", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": {} +} diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts new file mode 100644 index 0000000..2f414c7 --- /dev/null +++ b/packages/email/src/index.ts @@ -0,0 +1,11 @@ +/** + * Transactional email. + * + * Only account mail goes through here — verification and password-adjacent + * notices. Outreach never does: that is the approval queue's job, and routing + * it through the same sender would make "we sent this on your behalf" and "we + * sent you a receipt" indistinguishable in a provider's logs. + */ + +export { ConsoleMailer, ResendMailer, type Mailer, type Message } from './mailer'; +export { verificationEmail } from './templates'; diff --git a/packages/email/src/mailer.test.ts b/packages/email/src/mailer.test.ts new file mode 100644 index 0000000..822069c --- /dev/null +++ b/packages/email/src/mailer.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test'; +import { ConsoleMailer, MailerError, ResendMailer } from './mailer'; +import { verificationEmail } from './templates'; + +function captureFetch(): { calls: RequestInit[]; impl: typeof fetch } { + const calls: RequestInit[] = []; + const impl = (async (_url: string, init: RequestInit) => { + calls.push(init); + return new Response('{"id":"1"}', { status: 200 }); + }) as unknown as typeof fetch; + + return { calls, impl }; +} + +describe('ResendMailer', () => { + test('sends the message with the configured sender', async () => { + const { calls, impl } = captureFetch(); + const mailer = new ResendMailer({ apiKey: 'key', from: 'hi@og.com', fetchImpl: impl }); + + await mailer.send({ to: 'a@b.com', subject: 'Hi', text: 'body' }); + + const body = JSON.parse(String(calls[0]?.body)); + expect(body.from).toBe('hi@og.com'); + expect(body.to).toEqual(['a@b.com']); + expect(body.subject).toBe('Hi'); + }); + + test('the API key travels as a bearer token, never in the body', async () => { + const { calls, impl } = captureFetch(); + await new ResendMailer({ apiKey: 'secret', from: 'a@b.com', fetchImpl: impl }).send({ + to: 'c@d.com', + subject: 'x', + text: 'y', + }); + + const headers = calls[0]?.headers as Record; + expect(headers.authorization).toBe('Bearer secret'); + expect(String(calls[0]?.body)).not.toContain('secret'); + }); + + test('a rejection carries the provider reason rather than a bare status', async () => { + const impl = (async () => + new Response('domain not verified', { status: 403 })) as unknown as typeof fetch; + + const mailer = new ResendMailer({ apiKey: 'k', from: 'a@b.com', fetchImpl: impl }); + + // Every failure looking identical is what makes "email is broken" take a + // day to diagnose instead of a minute. + await expect(mailer.send({ to: 'c@d.com', subject: 'x', text: 'y' })).rejects.toThrow( + /domain not verified/, + ); + }); + + test('the thrown error exposes the status for callers that branch on it', async () => { + const impl = (async () => new Response('nope', { status: 429 })) as unknown as typeof fetch; + + try { + await new ResendMailer({ apiKey: 'k', from: 'a@b.com', fetchImpl: impl }).send({ + to: 'c@d.com', + subject: 'x', + text: 'y', + }); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(MailerError); + expect((error as MailerError).status).toBe(429); + } + }); +}); + +describe('ConsoleMailer', () => { + test('logs rather than sends, so signup completes with no credentials', async () => { + const lines: string[] = []; + await new ConsoleMailer((line) => lines.push(line)).send({ + to: 'a@b.com', + subject: 'Confirm', + text: 'https://example.com/verify?token=abc', + }); + + expect(lines[0]).toContain('a@b.com'); + // The link must be recoverable from the log or local signup is a dead end. + expect(lines[0]).toContain('token=abc'); + }); +}); + +describe('verificationEmail', () => { + test('the plain-text part carries the whole link', async () => { + const message = verificationEmail('a@b.com', 'https://og.com/verify?token=xyz'); + + expect(message.text).toContain('https://og.com/verify?token=xyz'); + expect(message.to).toBe('a@b.com'); + }); + + test('a link with markup in it cannot break out of the html', async () => { + const message = verificationEmail( + 'a@b.com', + 'https://og.com/verify?token=">', + ); + + expect(message.html).not.toContain('