From 2e4a34b6f9ec4ed6a96a63aef1aaca865964cbfd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 21 Sep 2026 17:25:32 +0000 Subject: [PATCH] An agent-facing AutoGTM API: keys, dollar budgets, inbox, suppress lists, import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /api/v1/autogtm/*, shaped like Explee's AutoGTM API so an agent that already drives that product can drive this one with a base URL and a key. Every route translates onto code that was already here — campaigns, the policy engine, the approval path, credits, the importer, analytics — and there is still exactly one way to send. Underneath: workspace API keys (migration 0038, hashed, shown once, cannot mint keys); USD daily limits stored beside the action caps the engine enforces, with CONTACT_PRICE_USD as the exchange rate; a project-level allocator that splits a ceiling across campaigns by reply rate on every worker tick; suppression by email and domain through one shared key builder; and a policy change so that answering someone who wrote to us is not paced like cold outreach (still human-approved, still capped). llms.txt and openapi.json are rendered from one table and served keyless; a test refuses a documented route that does not exist. The hand-recorded execute path now attributes its interaction to the card's campaign. Not built on purpose: lead data (buy per credit) and a pre-warmed sending domain pool (the real ops burden). See the README section. Co-Authored-By: Claude Fable 5.1 --- README.md | 24 + apps/api/src/api-keys.test.ts | 183 ++ apps/api/src/api-keys.ts | 174 ++ apps/api/src/app.ts | 139 +- apps/api/src/auth.ts | 4 +- apps/api/src/autogtm-docs.ts | 936 +++++++++ apps/api/src/autogtm.test.ts | 721 +++++++ apps/api/src/autogtm.ts | 1692 +++++++++++++++++ apps/api/src/campaigns.ts | 4 +- apps/api/src/context.ts | 6 + apps/api/src/repository.ts | 15 +- apps/server/src/index.ts | 12 + apps/web/app/(app)/settings/page.tsx | 11 +- apps/web/components/api-keys-form.tsx | 161 ++ apps/web/lib/api.ts | 14 + migrations/0038_autogtm.sql | 54 + packages/domain/src/autogtm.test.ts | 146 ++ packages/domain/src/autogtm.ts | 175 ++ packages/domain/src/ids.ts | 1 + packages/domain/src/index.ts | 1 + packages/pipeline/src/cadence-runner.ts | 14 +- packages/pipeline/src/index.ts | 17 + packages/pipeline/src/pipeline.ts | 14 +- packages/pipeline/src/project-budgets.test.ts | 158 ++ packages/pipeline/src/project-budgets.ts | 221 +++ .../pipeline/src/suppression-keys.test.ts | 14 + packages/pipeline/src/suppression-keys.ts | 126 ++ packages/policy/src/engine.test.ts | 46 + packages/policy/src/engine.ts | 14 +- 29 files changed, 5050 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/api-keys.test.ts create mode 100644 apps/api/src/api-keys.ts create mode 100644 apps/api/src/autogtm-docs.ts create mode 100644 apps/api/src/autogtm.test.ts create mode 100644 apps/api/src/autogtm.ts create mode 100644 apps/web/components/api-keys-form.tsx create mode 100644 migrations/0038_autogtm.sql create mode 100644 packages/domain/src/autogtm.test.ts create mode 100644 packages/domain/src/autogtm.ts create mode 100644 packages/pipeline/src/project-budgets.test.ts create mode 100644 packages/pipeline/src/project-budgets.ts create mode 100644 packages/pipeline/src/suppression-keys.test.ts create mode 100644 packages/pipeline/src/suppression-keys.ts diff --git a/README.md b/README.md index ccc030f..b4f5a08 100644 --- a/README.md +++ b/README.md @@ -247,3 +247,27 @@ score, a signal, a campaign or a workspace. One row shape, paged by `since` and an opaque `cursor`, cacheable for five minutes, sixty requests a minute per caller. The rule lives in `apps/api/src/public-directory.ts` and nowhere else; nichedb.dev reads it into its directory collection. + +## AutoGTM API + +The agent-facing surface: `/api/v1/autogtm/*`. Projects (one per product), campaigns with +budgets in dollars per day, a project-level autopilot that splits the ceiling across campaigns +by reply rate, an inbox with `need_reply` / `replied` / `sent` / `unsubscribed` tabs, replies, +hot leads, named suppress lists for addresses and domains, and an import that makes a campaign +from your own list. + +Read `/api/v1/public/llms.txt` first (the quick guide) and `/api/v1/public/openapi.json` for +the schema. Both are keyless and rendered from one table in `apps/api/src/autogtm-docs.ts`, so +a route cannot appear in one and not the other; a test refuses a documented route that does not +exist. + +Authenticate with a workspace key: mint one on `/settings` (or `POST /api/v1/api-keys` with a +session) and send it as `X-API-Key` on every request. A key acts with its owner's current +role and cannot mint keys. The service token and its scope headers still work for internal +callers. + +A dollar budget becomes `max_contacts_per_day = floor(usd / price_per_contact_usd)` on the +campaign, which the policy engine enforces at send time like any other cap. Nothing in this +surface can send outside the engine: a reply is a `send_email` card marked as a follow-up, +approved by the call itself, and refused with 409 when the lead is suppressed or the budget is +spent. diff --git a/apps/api/src/api-keys.test.ts b/apps/api/src/api-keys.test.ts new file mode 100644 index 0000000..08a792f --- /dev/null +++ b/apps/api/src/api-keys.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Hono } from 'hono'; +import { queryOne } from '@outreachgraph/db'; +import { createApp } from './app'; +import type { AppEnv, RequestActor } from './context'; +import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; +import { + actorFromApiKey, + listApiKeys, + mintApiKey, + presentedApiKey, + revokeApiKey, +} from './api-keys'; + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +const SESSION_ACTOR: RequestActor = { + userId: SEED.userId, + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + role: 'owner', + credential: 'session', +}; + +/** The real resolver: no `authenticate` override, so headers decide. */ +async function realApp(label: string): Promise<{ app: Hono; seeded: SeededDatabase }> { + const seeded = await seedDatabase(label); + active = seeded; + return { app: createApp({ db: seeded.db }), seeded }; +} + +describe('minting and using a key', () => { + test('the secret is hashed at rest and shown once', async () => { + const { seeded } = await realApp('key-mint'); + const minted = await mintApiKey(seeded.db, { + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + userId: SEED.userId, + name: 'agent', + }); + + expect(minted.key.startsWith('og_live_')).toBe(true); + expect(minted.prefix).toBe(minted.key.slice(0, minted.prefix.length)); + + const row = await queryOne<{ key_hash: string; key_prefix: string }>( + seeded.db, + 'SELECT key_hash, key_prefix FROM api_keys WHERE id = ?', + [minted.id], + ); + expect(row?.key_hash).not.toBe(minted.key); + expect(row?.key_hash).not.toContain(minted.key); + + const listed = await listApiKeys(seeded.db, SEED.workspaceId); + expect(listed).toHaveLength(1); + expect(JSON.stringify(listed)).not.toContain(minted.key); + }); + + test('a key authenticates as its owner, with their current role', async () => { + const { app, seeded } = await realApp('key-auth'); + const minted = await mintApiKey(seeded.db, { + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + userId: SEED.userId, + name: 'agent', + }); + + const actor = await actorFromApiKey(seeded.db, minted.key); + expect(actor?.workspaceId).toBe(SEED.workspaceId); + expect(actor?.role).toBe('owner'); + expect(actor?.credential).toBe('api_key'); + + // Through the app, with the header. + const viaHeader = await app.request('/api/v1/autogtm/projects', { + headers: { 'x-api-key': minted.key }, + }); + expect(viaHeader.status).toBe(200); + + const viaBearer = await app.request('/api/v1/autogtm/projects', { + headers: { authorization: `Bearer ${minted.key}` }, + }); + expect(viaBearer.status).toBe(200); + + const none = await app.request('/api/v1/autogtm/projects'); + expect(none.status).toBe(401); + + const wrong = await app.request('/api/v1/autogtm/projects', { + headers: { 'x-api-key': `${minted.key.slice(0, -4)}zzzz` }, + }); + expect(wrong.status).toBe(401); + }); + + test('a revoked key stops working, and a removed member takes their keys with them', async () => { + const { app, seeded } = await realApp('key-revoke'); + const minted = await mintApiKey(seeded.db, { + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + userId: SEED.userId, + name: 'agent', + }); + + expect(await revokeApiKey(seeded.db, SEED.workspaceId, minted.id)).toBe(true); + expect(await revokeApiKey(seeded.db, SEED.workspaceId, minted.id)).toBe(false); + + const gone = await app.request('/api/v1/autogtm/projects', { + headers: { 'x-api-key': minted.key }, + }); + expect(gone.status).toBe(401); + + const second = await mintApiKey(seeded.db, { + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + userId: SEED.userId, + name: 'agent-2', + }); + await seeded.db.execute({ + sql: 'DELETE FROM organization_members WHERE user_id = ?', + args: [SEED.userId], + }); + expect(await actorFromApiKey(seeded.db, second.key)).toBeUndefined(); + }); + + test('a key cannot mint keys; a session can', async () => { + const seeded = await seedDatabase('key-mint-route'); + active = seeded; + + const asKey = createApp({ + db: seeded.db, + authenticate: async () => ({ ...SESSION_ACTOR, credential: 'api_key' }), + }); + const refused = await asKey.request('/api/v1/api-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'nested' }), + }); + expect(refused.status).toBe(403); + + const asSession = createApp({ db: seeded.db, authenticate: async () => SESSION_ACTOR }); + const created = await asSession.request('/api/v1/api-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'my agent' }), + }); + expect(created.status).toBe(201); + const body = (await created.json()) as { key: { id: string; key: string; name: string } }; + expect(body.key.name).toBe('my agent'); + expect(body.key.key.startsWith('og_live_')).toBe(true); + + const listed = await asSession.request('/api/v1/api-keys'); + const list = (await listed.json()) as { keys: { id: string }[] }; + expect(list.keys.map((k) => k.id)).toContain(body.key.id); + + const revoked = await asSession.request(`/api/v1/api-keys/${body.key.id}`, { + method: 'DELETE', + }); + expect(revoked.status).toBe(200); + + const unnamed = await asSession.request('/api/v1/api-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(unnamed.status).toBe(400); + }); +}); + +describe('presentedApiKey', () => { + test('reads either header and ignores things that are not keys', () => { + const key = 'og_live_0123456789abcdef0123456789abcdef'; + expect(presentedApiKey(new Request('http://x', { headers: { 'x-api-key': key } }))).toBe(key); + expect( + presentedApiKey(new Request('http://x', { headers: { authorization: `Bearer ${key}` } })), + ).toBe(key); + expect( + presentedApiKey(new Request('http://x', { headers: { authorization: 'Bearer svc_token' } })), + ).toBeUndefined(); + expect(presentedApiKey(new Request('http://x'))).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api-keys.ts b/apps/api/src/api-keys.ts new file mode 100644 index 0000000..43d15db --- /dev/null +++ b/apps/api/src/api-keys.ts @@ -0,0 +1,174 @@ +/** + * Keys a workspace hands to its agents. + * + * The service token was the only machine credential, and it is the wrong + * shape for a customer: one secret for the whole deployment, plus two headers + * naming the workspace it should act on. A key here belongs to one workspace, + * carries one person's authority, is shown once, and dies with a click. + * + * Stored as a SHA-256 digest, the same way sessions are. The prefix kept + * beside it is for the list view — "og_live_8f3a…" tells you which key you + * are about to revoke without telling anyone what the key is. + */ + +import { newId } from '@outreachgraph/domain'; +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { hashToken, membershipForWorkspace } from './auth'; +import type { RequestActor } from './context'; + +export const API_KEY_PREFIX = 'og_live_'; +/** How many characters of the secret the list shows. */ +const VISIBLE = API_KEY_PREFIX.length + 6; + +export interface ApiKeySummary { + readonly id: string; + readonly name: string; + readonly prefix: string; + readonly createdAt: string; + readonly lastUsedAt: string | null; +} + +export interface MintedApiKey extends ApiKeySummary { + /** The secret. Returned from mint and never again. */ + readonly key: string; +} + +export class ApiKeyError extends Error { + constructor(message: string) { + super(message); + this.name = 'ApiKeyError'; + } +} + +export function mintSecret(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${API_KEY_PREFIX}${hex}`; +} + +export function looksLikeApiKey(value: string | null | undefined): value is string { + return typeof value === 'string' && value.startsWith(API_KEY_PREFIX) && value.length > VISIBLE; +} + +export async function mintApiKey( + db: Client, + input: { + readonly workspaceId: string; + readonly organizationId: string; + readonly userId: string; + readonly name: string; + }, +): Promise { + const name = input.name.trim().slice(0, 100); + if (!name) throw new ApiKeyError('a key needs a name'); + + const key = mintSecret(); + const id = newId('apiKey'); + const stamp = now(); + + await db.execute({ + sql: `INSERT INTO api_keys (id, workspace_id, organization_id, user_id, name, key_hash, + key_prefix, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + id, + input.workspaceId, + input.organizationId, + input.userId, + name, + await hashToken(key), + key.slice(0, VISIBLE), + stamp, + ], + }); + + return { id, name, prefix: key.slice(0, VISIBLE), createdAt: stamp, lastUsedAt: null, key }; +} + +export async function listApiKeys(db: Client, workspaceId: string): Promise { + const rows = await queryAll<{ + id: string; + name: string; + key_prefix: string; + created_at: string; + last_used_at: string | null; + }>( + db, + `SELECT id, name, key_prefix, created_at, last_used_at FROM api_keys + WHERE workspace_id = ? AND revoked_at IS NULL + ORDER BY created_at DESC`, + [workspaceId], + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + prefix: row.key_prefix, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + })); +} + +/** Revokes rather than deletes, so an audit trail can still name the key. */ +export async function revokeApiKey( + db: Client, + workspaceId: string, + keyId: string, +): Promise { + const result = await db.execute({ + sql: `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL`, + args: [now(), keyId, workspaceId], + }); + return result.rowsAffected > 0; +} + +/** + * The actor a presented key stands for, or undefined. + * + * Role comes from the owner's current membership, not from the key: a person + * demoted to viewer takes their keys down with them, and a person removed + * from the organization leaves keys that authenticate nobody. + */ +export async function actorFromApiKey( + db: Client, + presented: string, +): Promise { + if (!looksLikeApiKey(presented)) return undefined; + + const row = await queryOne<{ id: string; workspace_id: string; user_id: string }>( + db, + `SELECT id, workspace_id, user_id FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL`, + [await hashToken(presented)], + ); + if (!row) return undefined; + + const membership = await membershipForWorkspace(db, row.user_id, row.workspace_id); + if (!membership) return undefined; + + await db.execute({ + sql: 'UPDATE api_keys SET last_used_at = ? WHERE id = ?', + args: [now(), row.id], + }); + + return { + userId: row.user_id, + workspaceId: membership.workspaceId, + organizationId: membership.organizationId, + role: membership.role, + credential: 'api_key', + }; +} + +/** The key a request carries, from either header it may use. */ +export function presentedApiKey(request: Request): string | undefined { + const direct = request.headers.get('x-api-key'); + if (looksLikeApiKey(direct)) return direct; + + const bearer = request.headers.get('authorization'); + if (bearer?.startsWith('Bearer ')) { + const token = bearer.slice('Bearer '.length).trim(); + if (looksLikeApiKey(token)) return token; + } + + return undefined; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 22ab87c..96c2674 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -204,6 +204,16 @@ import { saveWorkspaceProfile, UnknownProductError, } from './workspace-profile'; +import { autogtmRoutes } from './autogtm'; +import { llmsText, openApiDocument } from './autogtm-docs'; +import { + actorFromApiKey, + ApiKeyError, + listApiKeys, + mintApiKey, + presentedApiKey, + revokeApiKey, +} from './api-keys'; /** One paste, one reviewable unit of work. */ const MAX_BULK_URLS = 100; @@ -491,6 +501,14 @@ export function createApp(options: AppOptions): Hono { const cookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE); if (cookie) { const actor = await actorFromSession(options.db, cookie); + if (actor) return { ...actor, credential: 'session' }; + } + + // A workspace key: `X-API-Key` or a bearer that looks like one. Scoped by + // the row it hashes to, so it needs no headers naming a workspace. + const apiKey = presentedApiKey(request); + if (apiKey) { + const actor = await actorFromApiKey(options.db, apiKey); if (actor) return actor; } @@ -509,6 +527,7 @@ export function createApp(options: AppOptions): Hono { workspaceId, organizationId, role: 'owner', + credential: 'service', }; } } @@ -1045,6 +1064,24 @@ export function createApp(options: AppOptions): Hono { return c.json(page); }); + // The API described for machines. Keyless: an agent reads these before it + // has a key, and there is nothing in them that is not in this file. + api.get('/public/openapi.json', (c) => { + c.header('cache-control', 'public, max-age=300'); + return c.json(openApiDocument(publicOrigin(c.req.raw), options.version ?? '0.0.0')); + }); + + api.get('/public/llms.txt', (c) => { + c.header('cache-control', 'public, max-age=300'); + return c.text(llmsText(publicOrigin(c.req.raw))); + }); + + const publicOrigin = (request: Request): string => { + if (options.apiUrl) return options.apiUrl.replace(/\/$/, ''); + if (options.appUrl) return options.appUrl.replace(/\/$/, ''); + return new URL(request.url).origin; + }; + // Everything else under /api/v1 is authenticated and workspace-scoped. api.use('*', async (c, next) => { const actor = await resolveActor(c.req.raw); @@ -1056,6 +1093,88 @@ export function createApp(options: AppOptions): Hono { await next(); }); + // ------------------------------------------------------------- api keys + // + // Minted by a person with a session, used by their agents. A key cannot + // mint keys: the credential that can create credentials stays with the + // human who signs in. + + api.get('/api-keys', async (c) => { + const actor = c.get('actor'); + return c.json({ keys: await listApiKeys(c.get('db'), actor.workspaceId) }); + }); + + api.post('/api-keys', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + + if (actor.credential === 'api_key') throw ApiError.forbidden('a key cannot mint keys'); + if (!canApprove(actor)) throw ApiError.forbidden('creating an API key'); + + const body = safeJson(await c.req.raw.text()); + const name = typeof body.name === 'string' ? body.name : ''; + + let minted; + try { + minted = await mintApiKey(db, { + workspaceId: actor.workspaceId, + organizationId: actor.organizationId, + userId: actor.userId, + name, + }); + } catch (error) { + if (error instanceof ApiKeyError) throw ApiError.badRequest(error.message); + throw error; + } + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'api_key.created', + entityKind: 'api_key', + entityId: minted.id, + detail: { name: minted.name, prefix: minted.prefix }, + }); + + return c.json({ key: minted }, 201); + }); + + api.delete('/api-keys/:id', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + if (!canApprove(actor)) throw ApiError.forbidden('revoking an API key'); + + const revoked = await revokeApiKey(db, actor.workspaceId, c.req.param('id')); + if (!revoked) throw ApiError.notFound('API key'); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'api_key.revoked', + entityKind: 'api_key', + entityId: c.req.param('id'), + detail: {}, + }); + + return c.json({ revoked: true, id: c.req.param('id') }); + }); + + // -------------------------------------------------------------- autogtm + // + // The agent-facing surface, in its own module. It receives the approval + // path rather than reimplementing it, which is what keeps this product + // with exactly one way to send. + api.route( + '/autogtm', + autogtmRoutes({ + approve: (db, actor, recommendation) => + approveRecommendation(db, options, actor, recommendation, {}), + requireVerifiedEmail: (db, actor) => requireVerifiedEmail(db, actor), + }), + ); + // ----------------------------------------------------------------- team // // An organization has been a party of one since the schema was written: @@ -3280,6 +3399,14 @@ export function createApp(options: AppOptions): Hono { ? await repo.resolveContactAddress(db, action.person_id) : undefined; + // Attributed to the campaign the card came from, as the automated path + // does; a null here is a message no per-campaign number can see. + const manualRecommendation = await repo.getRecommendation( + db, + actor.workspaceId, + action.recommendation_id, + ); + await db.batch([ { sql: `UPDATE actions SET status = 'completed', mode = ?, external_url = ?, executed_at = ? @@ -3287,13 +3414,14 @@ export function createApp(options: AppOptions): Hono { args: [body.mode, body.externalUrl ?? null, stamp, action.id], }, { - sql: `INSERT INTO interactions (id, workspace_id, person_id, action_id, network, direction, - state, contact_address, shared_inbox, occurred_at, recorded_at) - VALUES (?, ?, ?, ?, ?, 'outbound', 'contacted', ?, ?, ?, ?)`, + sql: `INSERT INTO interactions (id, workspace_id, person_id, campaign_id, action_id, network, + direction, state, contact_address, shared_inbox, occurred_at, recorded_at) + VALUES (?, ?, ?, ?, ?, ?, 'outbound', 'contacted', ?, ?, ?, ?)`, args: [ newId('interaction'), actor.workspaceId, action.person_id, + manualRecommendation?.campaign_id ?? null, action.id, action.network, manualContact?.address ?? null, @@ -4838,6 +4966,11 @@ async function recheckPolicy( : { hoursSinceLastActionToAddress: addressUsage.hoursSinceLast }), }), conversationOpen: replied, + // A `reply` card answers a message that came in. It is the only action + // the engine's 7b gate lets through on an open thread, and it still + // needs the approval this route is. + isFollowUp: + recommendation.action === 'reply' || recommendation.expected_goal === 'continue_conversation', budgetExhausted: budgetState.exhausted, featureFlags: flags, }); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 8535834..98cf9b0 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -308,7 +308,7 @@ export async function actorFromSession( }; } -interface Membership { +export interface Membership { readonly organizationId: string; readonly workspaceId: string; readonly role: RequestActor['role']; @@ -334,7 +334,7 @@ async function primaryMembership(db: Client, userId: string): Promise; +} + +interface Operation { + readonly method: 'get' | 'post' | 'patch' | 'delete'; + readonly path: string; + readonly id: string; + readonly summary: string; + readonly description?: string; + readonly tag: string; + readonly params?: readonly Param[]; + readonly body?: Record; + readonly response?: Record; + readonly status?: number; +} + +const usd = { type: 'number', minimum: 0 }; +const stringArray = { type: 'array', items: { type: 'string' } }; + +const targeting = { + type: 'object', + properties: { + titles: stringArray, + seniorities: stringArray, + industries: stringArray, + countries: stringArray, + keywords: stringArray, + technologies: stringArray, + exclusions: stringArray, + employee_count_min: { type: ['integer', 'null'] }, + employee_count_max: { type: ['integer', 'null'] }, + }, +}; + +const analytics = { + type: 'object', + properties: { + leads_pool: { type: 'integer', description: 'People in the campaign.' }, + contacted: { type: 'integer', description: 'Distinct people written to.' }, + emails_sent: { type: 'integer' }, + replies: { type: 'integer' }, + reply_rate: { type: 'number', description: 'replies / emails_sent, as a fraction.' }, + need_reply: { type: 'integer', description: 'Threads where the lead spoke last.' }, + hot_leads: { type: 'integer', description: 'Leads who replied or became an opportunity.' }, + awaiting_approval: { type: 'integer' }, + spend_usd: { type: 'number', description: 'contacted × price_per_contact_usd.' }, + cost_per_lead_usd: { type: ['number', 'null'], description: 'spend_usd / hot_leads.' }, + price_per_contact_usd: { type: 'number' }, + last_activity_at: { type: ['string', 'null'], format: 'date-time' }, + }, +}; + +const campaign = { + type: 'object', + properties: { + id: { type: 'string' }, + project_id: { type: 'string' }, + name: { type: 'string' }, + status: { + type: 'string', + enum: ['discovery', 'review', 'outreach', 'listening', 'archived'], + description: + 'discovery: finding leads; review: sends wait for a human; outreach: sending unattended; ' + + 'listening: paused, replies still land; archived: finished.', + }, + raw_status: { type: 'string' }, + autopilot: { type: 'boolean' }, + project_autopilot: { type: 'boolean' }, + daily_limit_usd: { type: ['number', 'null'] }, + max_contacts_per_day: { type: ['integer', 'null'] }, + source: { type: ['string', 'null'] }, + created_at: { type: 'string', format: 'date-time' }, + ...analytics.properties, + }, +}; + +const project = { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + domain: { type: ['string', 'null'] }, + daily_budget_usd: { type: ['number', 'null'] }, + autopilot: { type: 'boolean' }, + campaigns: { type: 'integer' }, + created_at: { type: 'string', format: 'date-time' }, + }, +}; + +const conversation = { + type: 'object', + properties: { + person_id: { type: 'string' }, + name: { type: 'string' }, + job_title: { type: ['string', 'null'] }, + company: { type: ['string', 'null'] }, + company_domain: { type: ['string', 'null'] }, + status: { type: 'string', enum: ['need_reply', 'replied', 'sent', 'unsubscribed'] }, + lead_status: { type: 'string' }, + messages: { + type: 'object', + properties: { inbound: { type: 'integer' }, outbound: { type: 'integer' } }, + }, + last_message_at: { type: 'string', format: 'date-time' }, + last_message_from: { type: 'string', enum: ['lead', 'you'] }, + last_message_preview: { type: ['string', 'null'] }, + note: { type: ['string', 'null'] }, + }, +}; + +const message = { + type: 'object', + properties: { + id: { type: 'string' }, + from: { type: 'string', enum: ['lead', 'you'] }, + network: { type: 'string' }, + subject: { type: ['string', 'null'] }, + body: { type: ['string', 'null'] }, + address: { type: ['string', 'null'] }, + at: { type: 'string', format: 'date-time' }, + }, +}; + +const idParam = (name: string, what: string): Param => ({ + name, + in: 'path', + required: true, + description: what, + schema: { type: 'string' }, +}); + +const sinceParam: Param = { + name: 'since', + in: 'query', + description: 'ISO instant. Only activity at or after this counts.', + schema: { type: 'string', format: 'date-time' }, +}; + +export const OPERATIONS: readonly Operation[] = [ + // ------------------------------------------------------------ projects + { + method: 'get', + path: '/autogtm/projects', + id: 'listProjects', + tag: 'Projects', + summary: 'List projects', + description: 'A project is one product you sell. Every campaign belongs to exactly one.', + response: { type: 'object', properties: { projects: { type: 'array', items: project } } }, + }, + { + method: 'get', + path: '/autogtm/projects/{project_id}', + id: 'getProject', + tag: 'Projects', + summary: 'Read one project', + params: [idParam('project_id', 'The project.')], + response: { type: 'object', properties: { project } }, + }, + { + method: 'get', + path: '/autogtm/projects/{project_id}/budget', + id: 'getProjectBudget', + tag: 'Budgets', + summary: 'Read a project’s daily ceiling and how it is split across campaigns', + params: [idParam('project_id', 'The project.')], + response: { + type: 'object', + properties: { + project_id: { type: 'string' }, + daily_budget_usd: { type: ['number', 'null'] }, + autopilot: { type: 'boolean' }, + allocated_usd: { type: 'number' }, + allocation: { + type: 'array', + items: { + type: 'object', + properties: { + campaign_id: { type: 'string' }, + name: { type: 'string' }, + status: { type: 'string' }, + daily_limit_usd: { type: ['number', 'null'] }, + }, + }, + }, + }, + }, + }, + { + method: 'patch', + path: '/autogtm/projects/{project_id}/budget', + id: 'setProjectBudget', + tag: 'Budgets', + summary: 'Set a project’s daily ceiling in dollars', + description: + 'Works whether or not autopilot is on. With autopilot on the ceiling is split across the ' + + 'project’s active campaigns by reply rate; with it off, hand-set campaign limits are scaled ' + + 'down only when together they would exceed it. `null` removes the ceiling.', + params: [idParam('project_id', 'The project.')], + body: { + type: 'object', + required: ['daily_budget_usd'], + properties: { daily_budget_usd: { ...usd, nullable: true } }, + }, + response: { + type: 'object', + properties: { + project_id: { type: 'string' }, + daily_budget_usd: { type: ['number', 'null'] }, + campaigns_reallocated: { type: 'integer' }, + }, + }, + }, + { + method: 'patch', + path: '/autogtm/projects/{project_id}/autopilot', + id: 'setProjectAutopilot', + tag: 'Projects', + summary: 'Turn autopilot on or off for a project', + description: + 'On: every campaign in the project sends unattended within its allocated budget, and ' + + 'per-campaign start/stop and budget calls answer 409 until it is off again. Off: sends ' + + 'wait in the approval queue for a human.', + params: [idParam('project_id', 'The project.')], + body: { type: 'object', required: ['enabled'], properties: { enabled: { type: 'boolean' } } }, + response: { + type: 'object', + properties: { + project_id: { type: 'string' }, + autopilot: { type: 'boolean' }, + campaigns: { type: 'integer' }, + campaigns_reallocated: { type: 'integer' }, + }, + }, + }, + { + method: 'get', + path: '/autogtm/projects/{project_id}/analytics', + id: 'getProjectAnalytics', + tag: 'Analytics', + summary: 'Totals for a project, with a row per campaign', + params: [idParam('project_id', 'The project.'), sinceParam], + response: { + type: 'object', + properties: { + project, + totals: analytics, + campaigns: { type: 'array', items: campaign }, + }, + }, + }, + + // ----------------------------------------------------------- campaigns + { + method: 'get', + path: '/autogtm/campaigns', + id: 'listCampaigns', + tag: 'Campaigns', + summary: 'List campaigns with their headline numbers', + params: [ + { + name: 'project_id', + in: 'query', + description: 'Only this project’s campaigns.', + schema: { type: 'string' }, + }, + { + name: 'include_archived', + in: 'query', + description: '`true` to include archived campaigns.', + schema: { type: 'boolean' }, + }, + ], + response: { type: 'object', properties: { campaigns: { type: 'array', items: campaign } } }, + }, + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}', + id: 'getCampaign', + tag: 'Campaigns', + summary: 'Read a campaign’s definition: targeting, instructions, budget, numbers', + params: [idParam('campaign_id', 'The campaign.')], + response: { + type: 'object', + properties: { + campaign: { + type: 'object', + properties: { + ...campaign.properties, + instructions: { type: ['string', 'null'] }, + targeting, + targeting_editable: { type: 'boolean' }, + }, + }, + }, + }, + }, + { + method: 'patch', + path: '/autogtm/campaigns/{campaign_id}', + id: 'updateCampaign', + tag: 'Campaigns', + summary: 'Change a campaign’s name, instructions, targeting or daily limit', + description: + 'Targeting changes affect who is researched and scored from now on; instructions affect ' + + 'future drafts only. `daily_limit_usd` is refused with 409 while the project is on autopilot.', + params: [idParam('campaign_id', 'The campaign.')], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + instructions: { type: 'string', description: 'What to say and how. Grounds every draft.' }, + targeting, + daily_limit_usd: { ...usd, nullable: true }, + }, + }, + }, + { + method: 'post', + path: '/autogtm/campaigns/{campaign_id}/start', + id: 'startCampaign', + tag: 'Campaigns', + summary: 'Resume a paused campaign', + description: 'Refused with 409 `autopilot_on` while the project is on autopilot.', + params: [idParam('campaign_id', 'The campaign.')], + response: { + type: 'object', + properties: { campaign_id: { type: 'string' }, status: { type: 'string' } }, + }, + }, + { + method: 'post', + path: '/autogtm/campaigns/{campaign_id}/stop', + id: 'stopCampaign', + tag: 'Campaigns', + summary: 'Pause a campaign; replies still land', + description: 'Refused with 409 `autopilot_on` while the project is on autopilot.', + params: [idParam('campaign_id', 'The campaign.')], + response: { + type: 'object', + properties: { campaign_id: { type: 'string' }, status: { type: 'string' } }, + }, + }, + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}/budget', + id: 'getCampaignBudget', + tag: 'Budgets', + summary: 'Read a campaign’s daily limit and the contact cap it becomes', + params: [idParam('campaign_id', 'The campaign.')], + response: { + type: 'object', + properties: { + campaign_id: { type: 'string' }, + daily_limit_usd: { type: ['number', 'null'] }, + max_contacts_per_day: { type: ['integer', 'null'] }, + price_per_contact_usd: { type: 'number' }, + managed_by_autopilot: { type: 'boolean' }, + }, + }, + }, + { + method: 'patch', + path: '/autogtm/campaigns/{campaign_id}/budget', + id: 'setCampaignBudget', + tag: 'Budgets', + summary: 'Set a campaign’s daily limit in dollars', + description: + 'Stored as `max_contacts_per_day = floor(daily_limit_usd / price_per_contact_usd)`, which ' + + 'the policy engine enforces on every send. Refused with 409 while the project is on autopilot. ' + + 'Still capped by the project’s ceiling when one is set.', + params: [idParam('campaign_id', 'The campaign.')], + body: { + type: 'object', + required: ['daily_limit_usd'], + properties: { daily_limit_usd: { ...usd, nullable: true } }, + }, + }, + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}/analytics', + id: 'getCampaignAnalytics', + tag: 'Analytics', + summary: 'A campaign’s numbers, optionally since an instant', + params: [idParam('campaign_id', 'The campaign.'), sinceParam], + response: { + type: 'object', + properties: { + campaign_id: { type: 'string' }, + status: { type: 'string' }, + ...analytics.properties, + }, + }, + }, + { + method: 'post', + path: '/autogtm/campaigns/import', + id: 'importCampaign', + tag: 'Campaigns', + summary: 'Create a campaign from your own list of leads', + description: + 'Up to 5,000 leads per request. Each becomes a person with a consented address, joins the ' + + 'campaign, and has their company site queued for research under it — a lead is written to ' + + 'only once something is known about them, because every message is grounded in evidence. ' + + 'The response is immediate; the `task_id` can be polled but is already complete.', + body: { + type: 'object', + required: ['name', 'leads'], + properties: { + name: { type: 'string' }, + project_id: { type: 'string', description: 'Defaults to your first project.' }, + instructions: { type: 'string' }, + autopilot: { type: 'boolean', description: 'Send unattended once leads are ready.' }, + consent_basis: { type: 'string', description: 'How these people agreed to hear from you.' }, + consent_source: { type: 'string' }, + leads: { + type: 'array', + minItems: 1, + maxItems: 5000, + items: { + type: 'object', + required: ['email'], + properties: { + email: { type: 'string', format: 'email' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + company_domain: { type: 'string' }, + company: { type: 'string' }, + job_title: { type: 'string' }, + location: { type: 'string' }, + }, + }, + }, + }, + }, + status: 201, + response: { + type: 'object', + properties: { + task_id: { type: 'string' }, + campaign_id: { type: 'string' }, + project_id: { type: 'string' }, + status: { type: 'string' }, + imported: { type: 'integer' }, + merged: { + type: 'integer', + description: 'Already on file; updated rather than duplicated.', + }, + rejected: { type: 'integer' }, + crawls_queued: { type: 'integer' }, + }, + }, + }, + { + method: 'get', + path: '/autogtm/campaigns/import/{task_id}', + id: 'getImport', + tag: 'Campaigns', + summary: 'Read an import’s outcome', + params: [idParam('task_id', 'The task_id an import returned.')], + response: { + type: 'object', + properties: { + task_id: { type: 'string' }, + campaign_id: { type: ['string', 'null'] }, + status: { type: 'string', enum: ['pending', 'completed', 'failed'] }, + total_rows: { type: 'integer' }, + imported: { type: 'integer' }, + merged: { type: 'integer' }, + rejected: { type: 'integer' }, + }, + }, + }, + + // --------------------------------------------------------------- inbox + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}/inbox', + id: 'listInbox', + tag: 'Inbox', + summary: 'Conversations in a campaign, newest first', + params: [ + idParam('campaign_id', 'The campaign.'), + { + name: 'tab', + in: 'query', + description: 'need_reply (lead spoke last), replied, sent, unsubscribed, or all.', + schema: { type: 'string', enum: ['all', 'need_reply', 'replied', 'sent', 'unsubscribed'] }, + }, + { + name: 'limit', + in: 'query', + description: '1–200, default 50.', + schema: { type: 'integer' }, + }, + { + name: 'before', + in: 'query', + description: 'Page cursor: the `next_before` of the previous page.', + schema: { type: 'string', format: 'date-time' }, + }, + ], + response: { + type: 'object', + properties: { + campaign_id: { type: 'string' }, + tab: { type: 'string' }, + conversations: { type: 'array', items: conversation }, + next_before: { type: 'string', format: 'date-time' }, + }, + }, + }, + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}/inbox/{person_id}', + id: 'getThread', + tag: 'Inbox', + summary: 'The full thread with one lead', + params: [idParam('campaign_id', 'The campaign.'), idParam('person_id', 'The lead.')], + response: { + type: 'object', + properties: { + campaign_id: { type: 'string' }, + lead: conversation, + messages: { type: 'array', items: message }, + }, + }, + }, + { + method: 'post', + path: '/autogtm/campaigns/{campaign_id}/inbox/{person_id}/reply', + id: 'replyToLead', + tag: 'Inbox', + summary: 'Reply to a lead', + description: + 'Text only; the subject and threading are handled. The reply passes the policy engine as a ' + + 'human-approved follow-up: suppression, budget and the daily cap still apply, and a lead who ' + + 'opted out cannot be written to (409 `policy_denied`). 502 means policy allowed it and the ' + + 'mailbox refused it.', + params: [idParam('campaign_id', 'The campaign.'), idParam('person_id', 'The lead.')], + body: { type: 'object', required: ['text'], properties: { text: { type: 'string' } } }, + response: { + type: 'object', + properties: { + sent: { type: 'boolean' }, + campaign_id: { type: 'string' }, + person_id: { type: 'string' }, + action_id: { type: 'string' }, + subject: { type: 'string' }, + to: { type: 'string' }, + }, + }, + }, + { + method: 'get', + path: '/autogtm/campaigns/{campaign_id}/inbox/{person_id}/note', + id: 'getLeadNote', + tag: 'Inbox', + summary: 'Read your note on a lead', + params: [idParam('campaign_id', 'The campaign.'), idParam('person_id', 'The lead.')], + response: { type: 'object', properties: { note: { type: ['string', 'null'] } } }, + }, + { + method: 'post', + path: '/autogtm/campaigns/{campaign_id}/inbox/{person_id}/note', + id: 'setLeadNote', + tag: 'Inbox', + summary: 'Write or clear your note on a lead', + params: [idParam('campaign_id', 'The campaign.'), idParam('person_id', 'The lead.')], + body: { + type: 'object', + required: ['note'], + properties: { note: { type: ['string', 'null'] } }, + }, + response: { type: 'object', properties: { note: { type: ['string', 'null'] } } }, + }, + { + method: 'get', + path: '/autogtm/hot-leads', + id: 'listHotLeads', + tag: 'Inbox', + summary: 'Every lead who has replied, across all campaigns, newest reply first', + description: 'Poll with `since` set to the previous call’s `polled_at` to see only new ones.', + params: [ + sinceParam, + { name: 'limit', in: 'query', description: '1–200.', schema: { type: 'integer' } }, + ], + response: { + type: 'object', + properties: { + hot_leads: { + type: 'array', + items: { + type: 'object', + properties: { + person_id: { type: 'string' }, + campaign_id: { type: 'string' }, + campaign_name: { type: 'string' }, + name: { type: 'string' }, + job_title: { type: ['string', 'null'] }, + company: { type: ['string', 'null'] }, + company_domain: { type: ['string', 'null'] }, + lead_status: { type: 'string' }, + replied_at: { type: ['string', 'null'], format: 'date-time' }, + last_reply_preview: { type: ['string', 'null'] }, + note: { type: ['string', 'null'] }, + }, + }, + }, + polled_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + + // ------------------------------------------------------ suppress lists + { + method: 'get', + path: '/autogtm/suppress-list/people', + id: 'listPeopleSuppressLists', + tag: 'Suppress lists', + summary: 'Named lists of addresses never to write to', + }, + { + method: 'post', + path: '/autogtm/suppress-list/people', + id: 'suppressPeople', + tag: 'Suppress lists', + summary: 'Add email addresses to a named list', + description: + 'Matching is exact on the lowercased address. Anyone already on file who matches has their ' + + 'queued messages cancelled immediately; anyone found later under that address is never ' + + 'contacted. Suppression survives deletion of the person.', + body: { + type: 'object', + required: ['list_name', 'emails'], + properties: { + list_name: { type: 'string' }, + emails: { type: 'array', items: { type: 'string', format: 'email' } }, + reason: { + type: 'string', + enum: ['do_not_contact', 'customer_request', 'complaint', 'admin'], + }, + }, + }, + status: 201, + }, + { + method: 'get', + path: '/autogtm/suppress-list/people/{list_name}', + id: 'getPeopleSuppressList', + tag: 'Suppress lists', + summary: 'Read one list’s addresses', + params: [idParam('list_name', 'The list.')], + }, + { + method: 'delete', + path: '/autogtm/suppress-list/people/{list_name}', + id: 'deletePeopleSuppressList', + tag: 'Suppress lists', + summary: 'Delete a list and stop suppressing its addresses', + params: [idParam('list_name', 'The list.')], + }, + { + method: 'get', + path: '/autogtm/suppress-list/companies', + id: 'listCompanySuppressLists', + tag: 'Suppress lists', + summary: 'Named lists of company domains never to write to', + }, + { + method: 'post', + path: '/autogtm/suppress-list/companies', + id: 'suppressCompanies', + tag: 'Suppress lists', + summary: 'Add company domains to a named list', + description: 'Matching is exact on the normalised host (`www.` and paths stripped).', + body: { + type: 'object', + required: ['list_name', 'domains'], + properties: { + list_name: { type: 'string' }, + domains: { type: 'array', items: { type: 'string' } }, + reason: { + type: 'string', + enum: ['do_not_contact', 'customer_request', 'complaint', 'admin'], + }, + }, + }, + status: 201, + }, + { + method: 'get', + path: '/autogtm/suppress-list/companies/{list_name}', + id: 'getCompanySuppressList', + tag: 'Suppress lists', + summary: 'Read one list’s domains', + params: [idParam('list_name', 'The list.')], + }, + { + method: 'delete', + path: '/autogtm/suppress-list/companies/{list_name}', + id: 'deleteCompanySuppressList', + tag: 'Suppress lists', + summary: 'Delete a list and stop suppressing its domains', + params: [idParam('list_name', 'The list.')], + }, + + // ------------------------------------------------------------- billing + { + method: 'get', + path: '/autogtm/billing/balance', + id: 'getBalance', + tag: 'Billing', + summary: 'Plan allowance, credits and whether sending is exhausted', + }, + + // ------------------------------------------------------------ api keys + { + method: 'get', + path: '/api-keys', + id: 'listApiKeys', + tag: 'API keys', + summary: 'List this workspace’s keys (prefixes only)', + }, + { + method: 'post', + path: '/api-keys', + id: 'createApiKey', + tag: 'API keys', + summary: 'Mint a key; the secret is returned once', + description: 'Session only — a key cannot mint keys.', + body: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } }, + status: 201, + }, + { + method: 'delete', + path: '/api-keys/{key_id}', + id: 'revokeApiKey', + tag: 'API keys', + summary: 'Revoke a key', + params: [idParam('key_id', 'The key.')], + }, +]; + +const ERROR = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + details: {}, + }, + }, + }, +}; + +export function openApiDocument(baseUrl: string, version: string): Record { + const paths: Record> = {}; + + for (const op of OPERATIONS) { + const entry: Record = { + operationId: op.id, + summary: op.summary, + tags: [op.tag], + ...(op.description ? { description: op.description } : {}), + ...(op.params ? { parameters: op.params } : {}), + ...(op.body + ? { requestBody: { required: true, content: { 'application/json': { schema: op.body } } } } + : {}), + responses: { + [String(op.status ?? 200)]: { + description: 'OK', + ...(op.response ? { content: { 'application/json': { schema: op.response } } } : {}), + }, + '400': { description: 'Bad request', content: { 'application/json': { schema: ERROR } } }, + '401': { description: 'Missing or invalid API key' }, + '404': { description: 'Not found', content: { 'application/json': { schema: ERROR } } }, + '409': { + description: 'Refused: `autopilot_on` or `policy_denied`', + content: { 'application/json': { schema: ERROR } }, + }, + }, + security: [{ apiKey: [] }], + }; + paths[op.path] = { ...(paths[op.path] ?? {}), [op.method]: entry }; + } + + return { + openapi: '3.1.0', + info: { + title: 'OutreachGraph AutoGTM API', + version, + description: + 'Run B2B email outreach end to end — projects, campaigns, budgets in dollars, an inbox, ' + + 'hot leads and suppress lists — through one key. Every send passes a deterministic policy ' + + 'engine; nothing here can bypass suppression, budgets or human approval where it applies.', + }, + servers: [{ url: `${baseUrl}/api/v1` }], + components: { + securitySchemes: { + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: + 'Mint one at /settings or POST /api/v1/api-keys with a session. ' + + '`Authorization: Bearer ` is accepted too.', + }, + }, + }, + security: [{ apiKey: [] }], + paths, + }; +} + +export function llmsText(baseUrl: string): string { + const lines: string[] = []; + const api = `${baseUrl}/api/v1`; + + lines.push('# OutreachGraph AutoGTM API'); + lines.push(''); + lines.push( + '> Run B2B email outreach through one API key: find and research leads, send within a ' + + 'daily dollar budget, read and answer replies. Every send passes a deterministic policy ' + + 'engine that enforces suppression, budgets and human approval — an agent cannot talk its ' + + 'way past any of them.', + ); + lines.push(''); + lines.push('## Setup'); + lines.push(''); + lines.push(`- Base URL: \`${api}\``); + lines.push( + '- Header on every request: `X-API-Key: og_live_…` (or `Authorization: Bearer og_live_…`).', + ); + lines.push(`- Full schema: \`${api}/public/openapi.json\``); + lines.push( + '- Keys are minted at `/settings` in the app, or `POST /api-keys` with a signed-in session.', + ); + lines.push( + '- Errors are `{ "error": { "code", "message", "details?" } }`. 401 bad key, 404 not yours, 409 refused (`autopilot_on`, `policy_denied`).', + ); + lines.push(''); + lines.push('## Concepts'); + lines.push(''); + lines.push( + '- **Project** — one product you sell. Has a daily ceiling in dollars and an autopilot switch.', + ); + lines.push( + '- **Campaign** — one audience for one project. Has its own daily limit, a lead pool, targeting, instructions and an inbox.', + ); + lines.push( + '- **Statuses** — `discovery` (finding leads), `review` (sends wait for a human), `outreach` (sending unattended), `listening` (paused, replies still land), `archived`.', + ); + lines.push( + '- **Budget** — dollars per day become `max_contacts_per_day = floor(usd / price_per_contact_usd)`, which the policy engine enforces. Spend is `contacted × price_per_contact_usd`.', + ); + lines.push( + '- **Autopilot** — on a project, it flips every campaign to unattended sending and splits the ceiling across them by reply rate (an exploration slice keeps new campaigns alive). While on, per-campaign start/stop and budget calls answer 409; turn it off first.', + ); + lines.push( + '- **Grounding** — a lead is written to only once research has found something to say. Imported leads enter `discovery` and their company sites are read first.', + ); + lines.push(''); + lines.push('## A session, in order'); + lines.push(''); + lines.push( + '1. `GET /autogtm/projects`, then `GET /autogtm/campaigns` — what exists and how each is doing (`reply_rate`, `hot_leads`, `spend_usd`).', + ); + lines.push( + '2. `GET /autogtm/hot-leads` — who has replied. Poll with `since=`.', + ); + lines.push( + '3. `GET /autogtm/campaigns/{id}/inbox?tab=need_reply` — threads where the lead spoke last; `GET …/inbox/{person_id}` for the thread; `POST …/inbox/{person_id}/reply` with `{ "text" }` to answer.', + ); + lines.push( + '4. `PATCH /autogtm/projects/{id}/budget` `{ "daily_budget_usd" }` and `PATCH /autogtm/projects/{id}/autopilot` `{ "enabled" }` to steer spend.', + ); + lines.push( + '5. `POST /autogtm/campaigns/{id}/stop` / `/start`, `PATCH /autogtm/campaigns/{id}` for targeting and instructions, `PATCH …/budget` for a per-campaign limit (autopilot off).', + ); + lines.push( + '6. `POST /autogtm/suppress-list/people` `{ "list_name", "emails" }` or `/companies` `{ "list_name", "domains" }` — never write to these. Halts anything queued for them.', + ); + lines.push( + '7. `POST /autogtm/campaigns/import` `{ "name", "leads": [{ "email", "first_name", "last_name", "company_domain", "job_title" }] }` — a campaign from your own list, up to 5,000 per call.', + ); + lines.push( + '8. `GET /autogtm/billing/balance` — allowance, credits, and whether sending is exhausted.', + ); + lines.push(''); + lines.push('## Endpoints'); + lines.push(''); + + let tag = ''; + for (const op of OPERATIONS) { + if (op.tag !== tag) { + tag = op.tag; + lines.push(`### ${tag}`); + lines.push(''); + } + lines.push(`- \`${op.method.toUpperCase()} ${op.path}\` — ${op.summary}.`); + if (op.description) lines.push(` ${op.description}`); + } + + lines.push(''); + lines.push('## Rules the engine will not bend'); + lines.push(''); + lines.push( + '- A suppressed person, an opted-out address or a blocked domain is never written to.', + ); + lines.push( + '- A lead who has replied is out of cold outreach; only a reply to them is allowed, and it needs approval (your API call is that approval).', + ); + lines.push( + '- Daily caps, the project ceiling and the monthly allowance are checked at send time, not at approval time.', + ); + lines.push('- Nothing is posted to LinkedIn, and GitHub is read, never written.'); + lines.push(''); + + return `${lines.join('\n')}\n`; +} diff --git a/apps/api/src/autogtm.test.ts b/apps/api/src/autogtm.test.ts new file mode 100644 index 0000000..1e4085a --- /dev/null +++ b/apps/api/src/autogtm.test.ts @@ -0,0 +1,721 @@ +/** + * The AutoGTM surface, end to end against a real database. + * + * Every route here is a translation onto code that has its own tests, so + * these check the translation: that a dollar becomes the cap the engine + * reads, that autopilot really does take the wheel, that a reply goes through + * policy and out of the mailer, that a suppress list halts what was queued. + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Hono } from 'hono'; +import type { Mailer, Message } from '@outreachgraph/email'; +import { CONTACT_PRICE_USD, newId } from '@outreachgraph/domain'; +import { now, queryAll, queryOne } from '@outreachgraph/db'; +import { createApp, type AppOptions } from './app'; +import type { AppEnv, RequestActor } from './context'; +import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; +import { OPERATIONS } from './autogtm-docs'; + +const ACTOR: RequestActor = { + userId: SEED.userId, + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + role: 'owner', + credential: 'api_key', +}; + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +function stubMailer(): { mailer: Mailer; sent: Message[] } { + const sent: Message[] = []; + return { + sent, + mailer: { + async send(message) { + sent.push(message); + return { id: `msg_${sent.length}` }; + }, + }, + }; +} + +async function harness( + label: string, + extra: Partial = {}, + actor: RequestActor | null = ACTOR, +): Promise<{ app: Hono; seeded: SeededDatabase }> { + const seeded = await seedDatabase(label); + active = seeded; + const app = createApp({ + db: seeded.db, + authenticate: async () => actor ?? undefined, + ...extra, + }); + return { app, seeded }; +} + +const get = (app: Hono, path: string) => app.request(`/api/v1${path}`); +const send = (app: Hono, method: string, path: string, body?: unknown) => + app.request(`/api/v1${path}`, { + method, + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + +async function json>(response: Response): Promise { + return (await response.json()) as T; +} + +/** Jane gets an email address, one message out, and one reply in. */ +async function giveJaneAThread(seeded: SeededDatabase): Promise { + const { db } = seeded; + const stamp = now(); + const recId = newId('recommendation'); + const actionId = newId('action'); + + await db.batch([ + { + sql: `INSERT INTO social_identities (id, person_id, network, handle, platform_user_id, + confidence, source_type, verified_by, first_seen_at) + VALUES ('sid_jane_email', ?, 'email', 'jane@acme.com', NULL, 0.97, 'crawl', '[]', ?)`, + args: [SEED.personId, stamp], + }, + { + sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action, network, + priority, reason, policy_status, policy_version, expected_goal, status, created_at) + VALUES (?, ?, ?, ?, 'send_email', 'email', 80, 'seed', 'allow_with_approval', + '2026-08-11', 'start_conversation', 'executed', ?)`, + args: [recId, SEED.workspaceId, SEED.campaignId, SEED.personId, stamp], + }, + { + sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, subject, body, + grounded_signal_ids, checks_json, created_at, updated_at) + VALUES (?, ?, ?, 'Cross-border payouts at Acme', 'Hi Jane', '[]', '[]', ?, ?)`, + args: [newId('draft'), SEED.workspaceId, recId, stamp, stamp], + }, + { + sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network, + mode, status, body, created_at, executed_at) + VALUES (?, ?, ?, ?, 'send_email', 'email', 'customer_managed', 'completed', 'Hi Jane', + ?, ?)`, + args: [actionId, SEED.workspaceId, recId, SEED.personId, stamp, stamp], + }, + { + sql: `INSERT INTO interactions (id, workspace_id, person_id, campaign_id, action_id, network, + direction, state, body, contact_address, occurred_at, recorded_at) + VALUES (?, ?, ?, ?, ?, 'email', 'outbound', 'contacted', 'Hi Jane', 'jane@acme.com', + '2026-09-01T10:00:00.000Z', ?)`, + args: [ + newId('interaction'), + SEED.workspaceId, + SEED.personId, + SEED.campaignId, + actionId, + stamp, + ], + }, + { + sql: `INSERT INTO interactions (id, workspace_id, person_id, campaign_id, network, direction, + state, body, contact_address, occurred_at, recorded_at) + VALUES (?, ?, ?, ?, 'email', 'inbound', 'responded', 'Sure, tell me more', + 'jane@acme.com', '2026-09-02T09:00:00.000Z', ?)`, + args: [newId('interaction'), SEED.workspaceId, SEED.personId, SEED.campaignId, stamp], + }, + { + sql: `UPDATE campaign_people SET status = 'responded', interaction_state = 'responded' + WHERE campaign_id = ? AND person_id = ?`, + args: [SEED.campaignId, SEED.personId], + }, + ]); +} + +// ------------------------------------------------------------------ docs + +describe('the machine-readable docs', () => { + test('are keyless and agree with each other', async () => { + const { app } = await harness('docs', {}, null); + + const openapi = await get(app, '/public/openapi.json'); + expect(openapi.status).toBe(200); + const doc = await json<{ + paths: Record>; + info: { title: string }; + }>(openapi); + expect(doc.info.title).toContain('AutoGTM'); + + const llms = await get(app, '/public/llms.txt'); + expect(llms.status).toBe(200); + const text = await llms.text(); + expect(text).toContain('X-API-Key'); + + for (const op of OPERATIONS) { + expect(doc.paths[op.path]?.[op.method]).toBeDefined(); + expect(text).toContain(`${op.method.toUpperCase()} ${op.path}`); + } + }); + + test('every documented route exists', async () => { + const { app } = await harness('docs-routes'); + + for (const op of OPERATIONS) { + const path = op.path.replace(/\{[^}]+\}/g, 'nope'); + const response = await app.request(`/api/v1${path}`, { + method: op.method.toUpperCase(), + headers: { 'content-type': 'application/json' }, + body: op.method === 'get' || op.method === 'delete' ? undefined : '{}', + }); + // Anything but "route not found": 404 with a named entity, 400, 403... + if (response.status === 404) { + const body = await json<{ error: { message: string } }>(response); + expect(body.error.message).not.toBe('route not found'); + } + } + }); +}); + +// -------------------------------------------------------------- projects + +describe('projects', () => { + test('a project is an offering, with its budget and switch', async () => { + const { app } = await harness('projects'); + const listed = await json<{ + projects: { id: string; autopilot: boolean; campaigns: number }[]; + }>(await get(app, '/autogtm/projects')); + expect(listed.projects).toHaveLength(1); + expect(listed.projects[0]?.id).toBe(SEED.offeringId); + expect(listed.projects[0]?.autopilot).toBe(false); + expect(listed.projects[0]?.campaigns).toBe(1); + + const one = await get(app, `/autogtm/projects/${SEED.offeringId}`); + expect(one.status).toBe(200); + expect((await get(app, '/autogtm/projects/off_nope')).status).toBe(404); + }); + + test('a project ceiling with autopilot on becomes campaign caps', async () => { + const { app, seeded } = await harness('project-budget'); + + const set = await send(app, 'PATCH', `/autogtm/projects/${SEED.offeringId}/budget`, { + daily_budget_usd: 6, + }); + expect(set.status).toBe(200); + // Autopilot off: the ceiling is a cap on hand-set budgets, and there are none yet. + expect((await json(set)).campaigns_reallocated).toBe(0); + + const on = await send(app, 'PATCH', `/autogtm/projects/${SEED.offeringId}/autopilot`, { + enabled: true, + }); + expect(on.status).toBe(200); + expect((await json(on)).campaigns_reallocated).toBe(1); + + const campaign = await queryOne<{ approval_mode: string; budget_json: string }>( + seeded.db, + 'SELECT approval_mode, budget_json FROM campaigns WHERE id = ?', + [SEED.campaignId], + ); + expect(campaign?.approval_mode).toBe('trusted_automation'); + const budget = JSON.parse(campaign?.budget_json ?? '{}'); + expect(budget.dailyBudgetUsd).toBe(6); + expect(budget.maxActionsPerDay).toBe(Math.floor(6 / CONTACT_PRICE_USD)); + // The seed's other knob survives. + expect(budget.maxActionsPerProspectPerWeek).toBe(1); + + const read = await json<{ allocated_usd: number; allocation: { daily_limit_usd: number }[] }>( + await get(app, `/autogtm/projects/${SEED.offeringId}/budget`), + ); + expect(read.allocated_usd).toBe(6); + expect(read.allocation[0]?.daily_limit_usd).toBe(6); + + const off = await send(app, 'PATCH', `/autogtm/projects/${SEED.offeringId}/autopilot`, { + enabled: false, + }); + expect(off.status).toBe(200); + const after = await queryOne<{ approval_mode: string }>( + seeded.db, + 'SELECT approval_mode FROM campaigns WHERE id = ?', + [SEED.campaignId], + ); + expect(after?.approval_mode).toBe('draft_and_approve'); + }); + + test('a viewer may read but not spend', async () => { + const { app } = await harness('viewer', {}, { ...ACTOR, role: 'viewer' }); + expect((await get(app, '/autogtm/projects')).status).toBe(200); + const set = await send(app, 'PATCH', `/autogtm/projects/${SEED.offeringId}/budget`, { + daily_budget_usd: 6, + }); + expect(set.status).toBe(403); + }); +}); + +// ------------------------------------------------------------- campaigns + +describe('campaigns', () => { + test('list, read and the status vocabulary', async () => { + const { app } = await harness('campaigns'); + const list = await json<{ campaigns: { id: string; status: string; leads_pool: number }[] }>( + await get(app, '/autogtm/campaigns'), + ); + expect(list.campaigns).toHaveLength(1); + // Seeded as draft_and_approve + 'running': sends wait for a human. + expect(list.campaigns[0]?.status).toBe('review'); + expect(list.campaigns[0]?.leads_pool).toBe(1); + + const filtered = await json<{ campaigns: unknown[] }>( + await get(app, '/autogtm/campaigns?project_id=off_other'), + ); + expect(filtered.campaigns).toHaveLength(0); + + const one = await json<{ + campaign: { targeting: { titles: string[] }; instructions: unknown }; + }>(await get(app, `/autogtm/campaigns/${SEED.campaignId}`)); + expect(Array.isArray(one.campaign.targeting.titles)).toBe(true); + }); + + test('targeting and instructions are editable', async () => { + const { app, seeded } = await harness('campaign-patch'); + const patched = await send(app, 'PATCH', `/autogtm/campaigns/${SEED.campaignId}`, { + instructions: 'Lead with the settlement angle.', + targeting: { titles: ['VP Engineering', 'CTO'], employee_count_min: 50 }, + }); + expect(patched.status).toBe(200); + + const filters = await queryOne<{ titles: string; employee_count_min: number | null }>( + seeded.db, + 'SELECT titles, employee_count_min FROM campaign_filters WHERE campaign_id = ?', + [SEED.campaignId], + ); + expect(JSON.parse(filters?.titles ?? '[]')).toEqual(['VP Engineering', 'CTO']); + expect(filters?.employee_count_min).toBe(50); + + const brief = await queryOne<{ brief: string }>( + seeded.db, + 'SELECT brief FROM campaigns WHERE id = ?', + [SEED.campaignId], + ); + expect(brief?.brief).toBe('Lead with the settlement angle.'); + + const empty = await send(app, 'PATCH', `/autogtm/campaigns/${SEED.campaignId}`, {}); + expect(empty.status).toBe(400); + }); + + test('start, stop and a per-campaign budget, until autopilot owns them', async () => { + const { app, seeded } = await harness('campaign-control'); + + const stopped = await send(app, 'POST', `/autogtm/campaigns/${SEED.campaignId}/stop`); + expect(stopped.status).toBe(200); + expect((await json(stopped)).status).toBe('listening'); + + const started = await send(app, 'POST', `/autogtm/campaigns/${SEED.campaignId}/start`); + expect((await json(started)).status).toBe('outreach'); + + const budget = await send(app, 'PATCH', `/autogtm/campaigns/${SEED.campaignId}/budget`, { + daily_limit_usd: 1.5, + }); + expect(budget.status).toBe(200); + const stored = await json<{ daily_limit_usd: number; max_contacts_per_day: number }>(budget); + expect(stored.daily_limit_usd).toBe(1.5); + expect(stored.max_contacts_per_day).toBe(10); + + await send(app, 'PATCH', `/autogtm/projects/${SEED.offeringId}/autopilot`, { enabled: true }); + + const refused = await send(app, 'POST', `/autogtm/campaigns/${SEED.campaignId}/stop`); + expect(refused.status).toBe(409); + expect((await json<{ error: { code: string } }>(refused)).error.code).toBe('autopilot_on'); + + const refusedBudget = await send(app, 'PATCH', `/autogtm/campaigns/${SEED.campaignId}/budget`, { + daily_limit_usd: 3, + }); + expect(refusedBudget.status).toBe(409); + + const row = await queryOne<{ status: string }>( + seeded.db, + 'SELECT status FROM campaigns WHERE id = ?', + [SEED.campaignId], + ); + expect(row?.status).toBe('active'); + }); + + test('a campaign in another workspace is not reachable', async () => { + const { app } = await harness('campaign-scope', {}, { ...ACTOR, workspaceId: 'wsp_other' }); + expect((await get(app, `/autogtm/campaigns/${SEED.campaignId}`)).status).toBe(404); + expect((await send(app, 'POST', `/autogtm/campaigns/${SEED.campaignId}/stop`)).status).toBe( + 404, + ); + }); +}); + +// ------------------------------------------------------------- analytics + +describe('analytics', () => { + test('count what happened and price it', async () => { + const { app, seeded } = await harness('analytics'); + await giveJaneAThread(seeded); + + const a = await json<{ + emails_sent: number; + replies: number; + reply_rate: number; + hot_leads: number; + need_reply: number; + spend_usd: number; + cost_per_lead_usd: number; + }>(await get(app, `/autogtm/campaigns/${SEED.campaignId}/analytics`)); + + expect(a.emails_sent).toBe(1); + expect(a.replies).toBe(1); + expect(a.reply_rate).toBe(1); + expect(a.hot_leads).toBe(1); + expect(a.need_reply).toBe(1); + expect(a.spend_usd).toBe(CONTACT_PRICE_USD); + expect(a.cost_per_lead_usd).toBe(CONTACT_PRICE_USD); + + const windowed = await json<{ emails_sent: number }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/analytics?since=2026-09-02T00:00:00Z`), + ); + expect(windowed.emails_sent).toBe(0); + + const project = await json<{ totals: { replies: number }; campaigns: unknown[] }>( + await get(app, `/autogtm/projects/${SEED.offeringId}/analytics`), + ); + expect(project.totals.replies).toBe(1); + expect(project.campaigns).toHaveLength(1); + }); +}); + +// ----------------------------------------------------------------- inbox + +describe('inbox', () => { + test('tabs by who spoke last, and the thread reads in order', async () => { + const { app, seeded } = await harness('inbox'); + await giveJaneAThread(seeded); + + const need = await json<{ conversations: { person_id: string; status: string }[] }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox?tab=need_reply`), + ); + expect(need.conversations).toHaveLength(1); + expect(need.conversations[0]?.status).toBe('need_reply'); + + const sent = await json<{ conversations: unknown[] }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox?tab=sent`), + ); + expect(sent.conversations).toHaveLength(0); + + const bad = await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox?tab=spam`); + expect(bad.status).toBe(400); + + const thread = await json<{ messages: { from: string; subject: string | null }[] }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}`), + ); + expect(thread.messages.map((m) => m.from)).toEqual(['you', 'lead']); + expect(thread.messages[0]?.subject).toBe('Cross-border payouts at Acme'); + + expect((await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox/per_nope`)).status).toBe( + 404, + ); + }); + + test('a reply goes through policy and out of the mailer, threaded', async () => { + const { mailer, sent } = stubMailer(); + const { app, seeded } = await harness('inbox-reply', { mailer, appUrl: 'https://og.test' }); + await giveJaneAThread(seeded); + + const reply = await send( + app, + 'POST', + `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/reply`, + { text: 'Happy to. Does Thursday work?' }, + ); + expect(reply.status).toBe(200); + const body = await json<{ sent: boolean; subject: string; to: string }>(reply); + expect(body.sent).toBe(true); + expect(body.subject).toBe('Re: Cross-border payouts at Acme'); + expect(body.to).toBe('jane@acme.com'); + + expect(sent).toHaveLength(1); + expect(sent[0]?.text).toContain('Happy to. Does Thursday work?'); + + // Recorded like any other send: an outbound interaction, an approval, an action. + const outbound = await queryAll<{ direction: string }>( + seeded.db, + `SELECT direction FROM interactions WHERE person_id = ? ORDER BY occurred_at`, + [SEED.personId], + ); + expect(outbound.map((r) => r.direction)).toEqual(['outbound', 'inbound', 'outbound']); + + const tabs = await json<{ conversations: { status: string }[] }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox?tab=replied`), + ); + expect(tabs.conversations[0]?.status).toBe('replied'); + }); + + test('a reply to a suppressed lead is refused by policy', async () => { + const { mailer, sent } = stubMailer(); + const { app, seeded } = await harness('inbox-reply-suppressed', { mailer }); + await giveJaneAThread(seeded); + + const listed = await send(app, 'POST', '/autogtm/suppress-list/people', { + list_name: 'opted-out', + emails: ['Jane@Acme.com'], + }); + expect(listed.status).toBe(201); + + const reply = await send( + app, + 'POST', + `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/reply`, + { text: 'One more thing' }, + ); + expect(reply.status).toBe(409); + expect((await json<{ error: { code: string } }>(reply)).error.code).toBe('policy_denied'); + expect(sent).toHaveLength(0); + }); + + test('notes are per lead per campaign', async () => { + const { app } = await harness('inbox-note'); + + const set = await send( + app, + 'POST', + `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/note`, + { note: 'Wants a demo in Q4' }, + ); + expect(set.status).toBe(200); + + const read = await json<{ note: string }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/note`), + ); + expect(read.note).toBe('Wants a demo in Q4'); + + await send(app, 'POST', `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/note`, { + note: null, + }); + const cleared = await json<{ note: string | null }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox/${SEED.personId}/note`), + ); + expect(cleared.note).toBeNull(); + }); + + test('hot leads are the people who replied, across campaigns, pollable', async () => { + const { app, seeded } = await harness('hot-leads'); + + const before = await json<{ hot_leads: unknown[] }>(await get(app, '/autogtm/hot-leads')); + expect(before.hot_leads).toHaveLength(0); + + await giveJaneAThread(seeded); + + const after = await json<{ hot_leads: { person_id: string; last_reply_preview: string }[] }>( + await get(app, '/autogtm/hot-leads'), + ); + expect(after.hot_leads).toHaveLength(1); + expect(after.hot_leads[0]?.person_id).toBe(SEED.personId); + expect(after.hot_leads[0]?.last_reply_preview).toBe('Sure, tell me more'); + + const polled = await json<{ hot_leads: unknown[] }>( + await get(app, '/autogtm/hot-leads?since=2026-09-03T00:00:00Z'), + ); + expect(polled.hot_leads).toHaveLength(0); + }); +}); + +// -------------------------------------------------------- suppress lists + +describe('suppress lists', () => { + test('a people list halts what was queued and reads back', async () => { + const { app, seeded } = await harness('suppress-people'); + await giveJaneAThread(seeded); + + // The seed's pending card for Jane. + const pendingBefore = await queryOne<{ status: string }>( + seeded.db, + 'SELECT status FROM recommendations WHERE id = ?', + [SEED.recommendationId], + ); + expect(pendingBefore?.status).toBe('pending'); + + const created = await send(app, 'POST', '/autogtm/suppress-list/people', { + list_name: 'competitors', + emails: ['jane@acme.com', 'bob@example.com'], + }); + expect(created.status).toBe(201); + const result = await json<{ added: number; people_halted: number }>(created); + expect(result.added).toBe(2); + expect(result.people_halted).toBe(1); + + const pendingAfter = await queryOne<{ status: string }>( + seeded.db, + 'SELECT status FROM recommendations WHERE id = ?', + [SEED.recommendationId], + ); + expect(pendingAfter?.status).toBe('skipped'); + + const lists = await json<{ lists: { list_name: string; entries: number }[] }>( + await get(app, '/autogtm/suppress-list/people'), + ); + expect(lists.lists).toEqual([ + expect.objectContaining({ list_name: 'competitors', entries: 2 }), + ]); + + const one = await json<{ emails: string[] }>( + await get(app, '/autogtm/suppress-list/people/competitors'), + ); + expect(one.emails.sort()).toEqual(['bob@example.com', 'jane@acme.com']); + + const inbox = await json<{ conversations: { status: string }[] }>( + await get(app, `/autogtm/campaigns/${SEED.campaignId}/inbox?tab=unsubscribed`), + ); + expect(inbox.conversations[0]?.status).toBe('unsubscribed'); + + const deleted = await send(app, 'DELETE', '/autogtm/suppress-list/people/competitors'); + expect(deleted.status).toBe(200); + expect((await get(app, '/autogtm/suppress-list/people/competitors')).status).toBe(404); + + const keys = await queryOne<{ n: number }>( + seeded.db, + `SELECT count(*) AS n FROM suppression_keys WHERE workspace_id = ?`, + [SEED.workspaceId], + ); + expect(Number(keys?.n)).toBe(0); + }); + + test('a company list matches by normalised domain', async () => { + const { app } = await harness('suppress-companies'); + + const created = await send(app, 'POST', '/autogtm/suppress-list/companies', { + list_name: 'customers', + domains: ['https://www.Acme.com/', 'other.io'], + }); + expect(created.status).toBe(201); + const result = await json<{ people_halted: number }>(created); + // Jane works at acme.com. + expect(result.people_halted).toBe(1); + + const one = await json<{ domains: string[] }>( + await get(app, '/autogtm/suppress-list/companies/customers'), + ); + expect(one.domains.sort()).toEqual(['acme.com', 'other.io']); + }); +}); + +// ---------------------------------------------------------------- import + +describe('import', () => { + test('creates a campaign, its members, and queues their sites for research', async () => { + const { app, seeded } = await harness('import'); + + const created = await send(app, 'POST', '/autogtm/campaigns/import', { + name: 'Q4 list', + instructions: 'Short and specific.', + leads: [ + { + email: 'ana@northwind.io', + first_name: 'Ana', + last_name: 'Reyes', + company_domain: 'northwind.io', + job_title: 'Head of Ops', + }, + { email: 'sam@northwind.io', first_name: 'Sam', last_name: 'Lee' }, + { email: 'not-an-email', first_name: 'Nope' }, + { email: 'pat@gmail.com', first_name: 'Pat', last_name: 'Free' }, + ], + }); + // The bad row fails the schema, so the whole request is refused: an + // import that silently drops a row is an import nobody can reconcile. + expect(created.status).toBe(400); + + const ok = await send(app, 'POST', '/autogtm/campaigns/import', { + name: 'Q4 list', + instructions: 'Short and specific.', + leads: [ + { + email: 'ana@northwind.io', + first_name: 'Ana', + last_name: 'Reyes', + company_domain: 'northwind.io', + job_title: 'Head of Ops', + }, + { email: 'sam@northwind.io', first_name: 'Sam', last_name: 'Lee' }, + { email: 'pat@gmail.com', first_name: 'Pat', last_name: 'Free' }, + ], + }); + expect(ok.status).toBe(201); + const body = await json<{ + task_id: string; + campaign_id: string; + imported: number; + crawls_queued: number; + }>(ok); + expect(body.imported).toBe(3); + // Two at northwind.io share one crawl; gmail is nobody's company. + expect(body.crawls_queued).toBe(1); + + const members = await queryOne<{ n: number }>( + seeded.db, + 'SELECT count(*) AS n FROM campaign_people WHERE campaign_id = ?', + [body.campaign_id], + ); + expect(Number(members?.n)).toBe(3); + + const job = await queryOne<{ payload_json: string }>( + seeded.db, + `SELECT payload_json FROM jobs WHERE kind = 'crawl_site' AND workspace_id = ?`, + [SEED.workspaceId], + ); + expect(JSON.parse(job?.payload_json ?? '{}')).toEqual({ + url: 'https://northwind.io', + campaignId: body.campaign_id, + }); + + const campaign = await json<{ + campaign: { status: string; leads_pool: number; instructions: string }; + }>(await get(app, `/autogtm/campaigns/${body.campaign_id}`)); + expect(campaign.campaign.leads_pool).toBe(3); + expect(campaign.campaign.instructions).toBe('Short and specific.'); + + const task = await json<{ status: string; imported: number }>( + await get(app, `/autogtm/campaigns/import/${body.task_id}`), + ); + expect(task.status).toBe('completed'); + expect(task.imported).toBe(3); + + expect((await get(app, '/autogtm/campaigns/import/imp_nope')).status).toBe(404); + }); + + test('an unknown project is a 404, not a new offering', async () => { + const { app, seeded } = await harness('import-project'); + const refused = await send(app, 'POST', '/autogtm/campaigns/import', { + name: 'x', + project_id: 'off_nope', + leads: [{ email: 'a@b.co' }], + }); + expect(refused.status).toBe(404); + const offerings = await queryOne<{ n: number }>( + seeded.db, + 'SELECT count(*) AS n FROM offerings', + [], + ); + expect(Number(offerings?.n)).toBe(1); + }); +}); + +// --------------------------------------------------------------- billing + +describe('billing', () => { + test('balance reads the plan and credits', async () => { + const { app } = await harness('billing'); + const balance = await json<{ + plan: { id: string }; + credits: { remaining: number; price_per_contact_usd: number }; + exhausted: boolean; + }>(await get(app, '/autogtm/billing/balance')); + expect(balance.plan.id).toBe('free'); + expect(balance.credits.remaining).toBe(0); + expect(balance.credits.price_per_contact_usd).toBe(CONTACT_PRICE_USD); + expect(balance.exhausted).toBe(false); + }); +}); diff --git a/apps/api/src/autogtm.ts b/apps/api/src/autogtm.ts new file mode 100644 index 0000000..0b9e9d6 --- /dev/null +++ b/apps/api/src/autogtm.ts @@ -0,0 +1,1692 @@ +/** + * The AutoGTM surface: `/api/v1/autogtm/*`. + * + * A public API shaped the way agents already expect an outreach product to be + * shaped — projects, campaigns, budgets in dollars, an inbox, hot leads, + * suppress lists, an import — and backed entirely by what was already here. + * Nothing in this file sends a message, scores a lead or decides a policy. + * It translates, and then it calls the same code the approval queue and the + * worker call. + * + * Words, so the mapping is not a secret: + * + * - A **project** is an offering: one product, one daily ceiling. + * - A campaign's **daily limit** is dollars per day, stored on the campaign + * beside the action caps the policy engine enforces, and converted at + * `CONTACT_PRICE_USD` — see `packages/domain/src/autogtm.ts`. + * - **Autopilot** on a project puts its campaigns into `trusted_automation` + * and hands their budgets to the allocator. While it is on, per-campaign + * start/stop and budget calls answer 409, because two hands on one wheel + * is how a customer ends up not knowing why a campaign paused. + * - The **inbox** is `interactions`, grouped by person, tabbed by who spoke + * last. A reply is a `reply` recommendation approved by the caller, which + * is the only way anything leaves this product: through the policy engine. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { + autogtmStatus, + CONTACT_PRICE_USD, + newId, + replyRate, + usdForContacts, +} from '@outreachgraph/domain'; +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { + applyProjectBudget, + budgetStatus, + campaignBudgetFrom, + crawlDedupeKey, + domainMatchKey, + emailMatchKey, + enqueue, + finishContactImport, + importContactChunk, + normaliseDomain, + peopleMatchingKeys, + recordDiscovered, + setCampaignDailyBudget, + startContactImport, +} from '@outreachgraph/pipeline'; +import { POLICY_VERSION } from '@outreachgraph/policy'; +import { + ensureOffering, + inheritFilters, + setCampaignAutopilot, + setCampaignStatus, +} from './campaigns'; +import { ApiError, canApprove, type AppEnv, type RequestActor } from './context'; +import * as repo from './repository'; +import { UnknownProductError } from './workspace-profile'; + +/** Leads accepted in one import request. */ +export const IMPORT_MAX_LEADS = 5_000; +/** Rows handed to the importer at a time. */ +const IMPORT_CHUNK = 500; +/** Conversations or leads per page. */ +const PAGE_MAX = 200; +const PAGE_DEFAULT = 50; + +export interface ApproveResult { + readonly ok: boolean; + readonly actionId?: string; + readonly reason?: string; + readonly decision?: string; + readonly gate?: string | undefined; + readonly delivery?: { sent: boolean; to?: string; reason?: string }; +} + +export interface AutogtmDeps { + /** + * Approves a pending recommendation as the actor and, for an email, sends + * it. The API's own approval path, injected so this module cannot grow a + * second one. + */ + readonly approve: ( + db: Client, + actor: RequestActor, + recommendation: repo.RecommendationRow, + ) => Promise; + /** Refuses an unverified account anything that reaches a stranger. */ + readonly requireVerifiedEmail: (db: Client, actor: RequestActor) => Promise; +} + +// ------------------------------------------------------------------ schemas + +const budgetBody = z.object({ + daily_budget_usd: z.number().min(0).max(100_000).nullable(), +}); + +const campaignBudgetBody = z.object({ + daily_limit_usd: z.number().min(0).max(100_000).nullable(), +}); + +const autopilotBody = z.object({ enabled: z.boolean() }); + +const stringList = z.array(z.string().trim().min(1).max(120)).max(100); + +const targetingBody = z + .object({ + titles: stringList, + seniorities: stringList, + industries: stringList, + countries: stringList, + keywords: stringList, + technologies: stringList, + exclusions: stringList, + employee_count_min: z.number().int().min(0).nullable(), + employee_count_max: z.number().int().min(0).nullable(), + }) + .partial(); + +const campaignPatchBody = z + .object({ + name: z.string().trim().min(1).max(200), + instructions: z.string().max(4_000), + targeting: targetingBody, + daily_limit_usd: z.number().min(0).max(100_000).nullable(), + }) + .partial(); + +const replyBody = z.object({ text: z.string().trim().min(1).max(20_000) }); + +const noteBody = z.object({ note: z.string().max(4_000).nullable() }); + +const suppressPeopleBody = z.object({ + list_name: z.string().trim().min(1).max(100), + emails: z.array(z.string().trim().email()).min(1).max(10_000), + reason: z.enum(['do_not_contact', 'customer_request', 'complaint', 'admin']).optional(), +}); + +const suppressCompaniesBody = z.object({ + list_name: z.string().trim().min(1).max(100), + domains: z.array(z.string().trim().min(3).max(253)).min(1).max(10_000), + reason: z.enum(['do_not_contact', 'customer_request', 'complaint', 'admin']).optional(), +}); + +const importLead = z.object({ + email: z.string().trim().email(), + first_name: z.string().trim().max(100).optional(), + last_name: z.string().trim().max(100).optional(), + company_domain: z.string().trim().max(253).optional(), + company: z.string().trim().max(200).optional(), + job_title: z.string().trim().max(200).optional(), + location: z.string().trim().max(200).optional(), +}); + +const importBody = z.object({ + name: z.string().trim().min(1).max(200), + project_id: z.string().min(1).optional(), + leads: z.array(importLead).min(1).max(IMPORT_MAX_LEADS), + instructions: z.string().max(4_000).optional(), + autopilot: z.boolean().optional(), + consent_basis: z.string().max(200).optional(), + consent_source: z.string().max(500).optional(), +}); + +// ------------------------------------------------------------------ rows + +interface CampaignRow { + readonly id: string; + readonly name: string; + readonly status: string; + readonly approval_mode: string; + readonly budget_json: string; + readonly offering_id: string; + readonly brief: string | null; + readonly seed_kind: string | null; + readonly created_at: string; + readonly started_at: string | null; + readonly updated_at: string; + readonly project_autopilot: number; +} + +interface ProjectRow { + readonly id: string; + readonly name: string; + readonly url: string | null; + readonly daily_budget_usd: number | null; + readonly autopilot: number; + readonly created_at: string; + readonly campaigns: number; +} + +async function ownedCampaign( + db: Client, + workspaceId: string, + campaignId: string, +): Promise { + const row = await queryOne( + db, + `SELECT c.id, c.name, c.status, c.approval_mode, c.budget_json, c.offering_id, c.brief, + c.seed_kind, c.created_at, c.started_at, c.updated_at, + o.autopilot AS project_autopilot + FROM campaigns c JOIN offerings o ON o.id = c.offering_id + WHERE c.id = ? AND c.workspace_id = ?`, + [campaignId, workspaceId], + ); + if (!row) throw ApiError.notFound('campaign'); + return row; +} + +async function ownedProject( + db: Client, + workspaceId: string, + projectId: string, +): Promise { + const row = await queryOne( + db, + `SELECT o.id, o.name, o.url, o.daily_budget_usd, o.autopilot, o.created_at, + (SELECT COUNT(*) FROM campaigns c + WHERE c.offering_id = o.id AND c.status != 'archived') AS campaigns + FROM offerings o WHERE o.id = ? AND o.workspace_id = ?`, + [projectId, workspaceId], + ); + if (!row) throw ApiError.notFound('project'); + return row; +} + +function projectView(row: ProjectRow) { + return { + id: row.id, + name: row.name, + domain: row.url ? normaliseDomain(row.url) : null, + daily_budget_usd: row.daily_budget_usd, + autopilot: row.autopilot === 1, + campaigns: Number(row.campaigns), + created_at: row.created_at, + }; +} + +interface CampaignStats { + readonly leads_pool: number; + readonly contacted: number; + readonly emails_sent: number; + readonly replies: number; + readonly need_reply: number; + readonly hot_leads: number; + readonly awaiting_approval: number; + readonly last_activity_at: string | null; +} + +/** + * The counts behind every campaign row. + * + * Messages are attributed to a campaign through membership — the person is in + * the campaign — rather than through `interactions.campaign_id`, which the + * hand-recorded paths leave null. A person in two campaigns counts in both, + * which is the honest answer to "how is this campaign doing" when the same + * lead was reached from either. + */ +async function campaignStats( + db: Client, + workspaceId: string, + campaignId: string, + since?: string, +): Promise { + const window = since ? 'AND i.occurred_at >= ?' : ''; + const windowArgs = since ? [since] : []; + + const row = await queryOne<{ + leads_pool: number; + contacted: number; + emails_sent: number; + replies: number; + need_reply: number; + hot_leads: number; + awaiting_approval: number; + last_activity_at: string | null; + }>( + db, + `SELECT + (SELECT COUNT(*) FROM campaign_people cp JOIN people p ON p.id = cp.person_id + WHERE cp.campaign_id = ? AND p.status != 'deleted') AS leads_pool, + (SELECT COUNT(DISTINCT i.person_id) FROM interactions i + JOIN campaign_people cp ON cp.person_id = i.person_id AND cp.campaign_id = ? + WHERE i.workspace_id = ? AND i.direction = 'outbound' ${window}) AS contacted, + (SELECT COUNT(*) FROM interactions i + JOIN campaign_people cp ON cp.person_id = i.person_id AND cp.campaign_id = ? + WHERE i.workspace_id = ? AND i.direction = 'outbound' AND i.network = 'email' + ${window}) AS emails_sent, + (SELECT COUNT(*) FROM interactions i + JOIN campaign_people cp ON cp.person_id = i.person_id AND cp.campaign_id = ? + WHERE i.workspace_id = ? AND i.direction = 'inbound' ${window}) AS replies, + (SELECT COUNT(*) FROM campaign_people cp + WHERE cp.campaign_id = ? AND cp.interaction_state = 'responded' + AND NOT EXISTS ( + SELECT 1 FROM interactions o + WHERE o.workspace_id = ? AND o.person_id = cp.person_id AND o.direction = 'outbound' + AND o.occurred_at > (SELECT MAX(x.occurred_at) FROM interactions x + WHERE x.workspace_id = o.workspace_id + AND x.person_id = cp.person_id + AND x.direction = 'inbound'))) AS need_reply, + (SELECT COUNT(*) FROM campaign_people cp + WHERE cp.campaign_id = ? + AND cp.status IN ('responded', 'qualified_opportunity')) AS hot_leads, + (SELECT COUNT(*) FROM recommendations r + WHERE r.campaign_id = ? AND r.status = 'pending') AS awaiting_approval, + (SELECT MAX(e.occurred_at) FROM lead_stage_events e + WHERE e.campaign_id = ?) AS last_activity_at`, + [ + campaignId, + campaignId, + workspaceId, + ...windowArgs, + campaignId, + workspaceId, + ...windowArgs, + campaignId, + workspaceId, + ...windowArgs, + campaignId, + workspaceId, + campaignId, + campaignId, + campaignId, + ], + ); + + return { + leads_pool: Number(row?.leads_pool ?? 0), + contacted: Number(row?.contacted ?? 0), + emails_sent: Number(row?.emails_sent ?? 0), + replies: Number(row?.replies ?? 0), + need_reply: Number(row?.need_reply ?? 0), + hot_leads: Number(row?.hot_leads ?? 0), + awaiting_approval: Number(row?.awaiting_approval ?? 0), + last_activity_at: row?.last_activity_at ?? null, + }; +} + +function analyticsFrom(stats: CampaignStats) { + const spend = usdForContacts(stats.contacted); + return { + leads_pool: stats.leads_pool, + contacted: stats.contacted, + emails_sent: stats.emails_sent, + replies: stats.replies, + reply_rate: replyRate(stats.emails_sent, stats.replies), + need_reply: stats.need_reply, + hot_leads: stats.hot_leads, + awaiting_approval: stats.awaiting_approval, + spend_usd: spend, + cost_per_lead_usd: + stats.hot_leads > 0 ? Math.round((spend / stats.hot_leads) * 100) / 100 : null, + price_per_contact_usd: CONTACT_PRICE_USD, + last_activity_at: stats.last_activity_at, + }; +} + +function campaignView(row: CampaignRow, stats: CampaignStats) { + const budget = campaignBudgetFrom(row.budget_json); + return { + id: row.id, + project_id: row.offering_id, + name: row.name, + status: autogtmStatus({ ...row, contacted: stats.contacted }), + raw_status: row.status, + autopilot: row.approval_mode === 'trusted_automation', + project_autopilot: row.project_autopilot === 1, + daily_limit_usd: budget.dailyBudgetUsd ?? null, + max_contacts_per_day: budget.maxActionsPerDay ?? null, + source: row.seed_kind, + created_at: row.created_at, + started_at: row.started_at, + updated_at: row.updated_at, + ...analyticsFrom(stats), + }; +} + +async function targetingFor(db: Client, campaignId: string) { + const row = await queryOne<{ + titles: string; + seniorities: string; + industries: string; + countries: string; + keywords: string; + technologies: string; + exclusions: string; + employee_count_min: number | null; + employee_count_max: number | null; + }>( + db, + `SELECT titles, seniorities, industries, countries, keywords, technologies, exclusions, + employee_count_min, employee_count_max + FROM campaign_filters WHERE campaign_id = ?`, + [campaignId], + ); + + const list = (text: string | undefined): string[] => { + if (!text) return []; + try { + const parsed: unknown = JSON.parse(text); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } + }; + + return { + titles: list(row?.titles), + seniorities: list(row?.seniorities), + industries: list(row?.industries), + countries: list(row?.countries), + keywords: list(row?.keywords), + technologies: list(row?.technologies), + exclusions: list(row?.exclusions), + employee_count_min: row?.employee_count_min ?? null, + employee_count_max: row?.employee_count_max ?? null, + }; +} + +async function saveTargeting( + db: Client, + campaignId: string, + patch: z.infer, +): Promise { + const current = await targetingFor(db, campaignId); + const next = { ...current, ...stripUndefined(patch) }; + const stamp = now(); + + await db.execute({ + sql: `INSERT INTO campaign_filters (campaign_id, titles, seniorities, industries, countries, + keywords, technologies, exclusions, employee_count_min, employee_count_max, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(campaign_id) DO UPDATE SET + titles = excluded.titles, seniorities = excluded.seniorities, + industries = excluded.industries, countries = excluded.countries, + keywords = excluded.keywords, technologies = excluded.technologies, + exclusions = excluded.exclusions, + employee_count_min = excluded.employee_count_min, + employee_count_max = excluded.employee_count_max, + updated_at = excluded.updated_at`, + args: [ + campaignId, + JSON.stringify(next.titles), + JSON.stringify(next.seniorities), + JSON.stringify(next.industries), + JSON.stringify(next.countries), + JSON.stringify(next.keywords), + JSON.stringify(next.technologies), + JSON.stringify(next.exclusions), + next.employee_count_min, + next.employee_count_max, + stamp, + ], + }); +} + +function stripUndefined>(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial; +} + +function clampPage(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return PAGE_DEFAULT; + return Math.min(PAGE_MAX, Math.floor(n)); +} + +async function parse(request: Request, schema: T): Promise> { + let json: unknown; + try { + json = await request.json(); + } catch { + throw ApiError.badRequest('body must be JSON'); + } + const result = schema.safeParse(json); + if (!result.success) { + const issue = result.error.issues[0]; + throw ApiError.badRequest( + `invalid body: ${issue ? `${issue.path.join('.') || 'body'} ${issue.message}` : 'bad shape'}`, + result.error.issues, + ); + } + return result.data; +} + +function autopilotOwnsIt(row: { readonly project_autopilot: number }): never { + throw new ApiError( + 409, + 'autopilot_on', + 'this project is on autopilot, which owns campaign start/stop and budgets; ' + + 'turn it off with PATCH /autogtm/projects/{project_id}/autopilot first', + ); + void row; +} + +function requireWriter(actor: RequestActor, what: string): void { + if (!canApprove(actor)) throw ApiError.forbidden(what); +} + +// ------------------------------------------------------------------ routes + +export function autogtmRoutes(deps: AutogtmDeps): Hono { + const r = new Hono(); + + // -------------------------------------------------------------- projects + + r.get('/projects', async (c) => { + const actor = c.get('actor'); + const rows = await queryAll( + c.get('db'), + `SELECT o.id, o.name, o.url, o.daily_budget_usd, o.autopilot, o.created_at, + (SELECT COUNT(*) FROM campaigns c + WHERE c.offering_id = o.id AND c.status != 'archived') AS campaigns + FROM offerings o WHERE o.workspace_id = ? ORDER BY o.created_at`, + [actor.workspaceId], + ); + return c.json({ projects: rows.map(projectView) }); + }); + + r.get('/projects/:id', async (c) => { + const actor = c.get('actor'); + const project = await ownedProject(c.get('db'), actor.workspaceId, c.req.param('id')); + return c.json({ project: projectView(project) }); + }); + + r.get('/projects/:id/budget', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const project = await ownedProject(db, actor.workspaceId, c.req.param('id')); + const campaigns = await queryAll<{ + id: string; + name: string; + status: string; + budget_json: string; + }>( + db, + `SELECT id, name, status, budget_json FROM campaigns + WHERE offering_id = ? AND workspace_id = ? AND status != 'archived' ORDER BY created_at`, + [project.id, actor.workspaceId], + ); + + const allocation = campaigns.map((row) => ({ + campaign_id: row.id, + name: row.name, + status: row.status, + daily_limit_usd: campaignBudgetFrom(row.budget_json).dailyBudgetUsd ?? null, + })); + + return c.json({ + project_id: project.id, + daily_budget_usd: project.daily_budget_usd, + autopilot: project.autopilot === 1, + allocated_usd: allocation.reduce((sum, row) => sum + (row.daily_limit_usd ?? 0), 0), + allocation, + }); + }); + + r.patch('/projects/:id/budget', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'changing a budget'); + + const project = await ownedProject(db, actor.workspaceId, c.req.param('id')); + const body = await parse(c.req.raw, budgetBody); + + await db.execute({ + sql: 'UPDATE offerings SET daily_budget_usd = ?, updated_at = ? WHERE id = ? AND workspace_id = ?', + args: [body.daily_budget_usd, now(), project.id, actor.workspaceId], + }); + const changed = await applyProjectBudget(db, actor.workspaceId, project.id); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'project.budget_changed', + entityKind: 'offering', + entityId: project.id, + detail: { dailyBudgetUsd: body.daily_budget_usd, campaignsReallocated: changed }, + }); + + return c.json({ + project_id: project.id, + daily_budget_usd: body.daily_budget_usd, + campaigns_reallocated: changed, + }); + }); + + r.patch('/projects/:id/autopilot', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'changing autopilot'); + + const project = await ownedProject(db, actor.workspaceId, c.req.param('id')); + const body = await parse(c.req.raw, autopilotBody); + + // Turning it on spends money without asking again. + if (body.enabled) await deps.requireVerifiedEmail(db, actor); + + await db.execute({ + sql: 'UPDATE offerings SET autopilot = ?, updated_at = ? WHERE id = ? AND workspace_id = ?', + args: [body.enabled ? 1 : 0, now(), project.id, actor.workspaceId], + }); + + const campaigns = await queryAll<{ id: string }>( + db, + `SELECT id FROM campaigns WHERE offering_id = ? AND workspace_id = ? AND status != 'archived'`, + [project.id, actor.workspaceId], + ); + for (const campaign of campaigns) { + await setCampaignAutopilot(db, actor.workspaceId, campaign.id, body.enabled); + } + const changed = await applyProjectBudget(db, actor.workspaceId, project.id); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: body.enabled ? 'project.autopilot_enabled' : 'project.autopilot_disabled', + entityKind: 'offering', + entityId: project.id, + detail: { campaigns: campaigns.length, campaignsReallocated: changed }, + }); + + return c.json({ + project_id: project.id, + autopilot: body.enabled, + campaigns: campaigns.length, + campaigns_reallocated: changed, + }); + }); + + r.get('/projects/:id/analytics', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const project = await ownedProject(db, actor.workspaceId, c.req.param('id')); + const since = c.req.query('since'); + + const rows = await queryAll( + db, + `SELECT c.id, c.name, c.status, c.approval_mode, c.budget_json, c.offering_id, c.brief, + c.seed_kind, c.created_at, c.started_at, c.updated_at, + o.autopilot AS project_autopilot + FROM campaigns c JOIN offerings o ON o.id = c.offering_id + WHERE c.offering_id = ? AND c.workspace_id = ? AND c.status != 'archived' + ORDER BY c.created_at`, + [project.id, actor.workspaceId], + ); + + const campaigns = []; + const totals = { + leads_pool: 0, + contacted: 0, + emails_sent: 0, + replies: 0, + need_reply: 0, + hot_leads: 0, + awaiting_approval: 0, + last_activity_at: null as string | null, + }; + + for (const row of rows) { + const stats = await campaignStats(db, actor.workspaceId, row.id, since); + campaigns.push(campaignView(row, stats)); + totals.leads_pool += stats.leads_pool; + totals.contacted += stats.contacted; + totals.emails_sent += stats.emails_sent; + totals.replies += stats.replies; + totals.need_reply += stats.need_reply; + totals.hot_leads += stats.hot_leads; + totals.awaiting_approval += stats.awaiting_approval; + if ( + stats.last_activity_at && + (!totals.last_activity_at || stats.last_activity_at > totals.last_activity_at) + ) { + totals.last_activity_at = stats.last_activity_at; + } + } + + return c.json({ + project: projectView(project), + ...(since ? { since } : {}), + totals: analyticsFrom(totals), + campaigns, + }); + }); + + // ------------------------------------------------------------- campaigns + + r.get('/campaigns', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const projectId = c.req.query('project_id'); + const includeArchived = c.req.query('include_archived') === 'true'; + + const rows = await queryAll( + db, + `SELECT c.id, c.name, c.status, c.approval_mode, c.budget_json, c.offering_id, c.brief, + c.seed_kind, c.created_at, c.started_at, c.updated_at, + o.autopilot AS project_autopilot + FROM campaigns c JOIN offerings o ON o.id = c.offering_id + WHERE c.workspace_id = ? + ${projectId ? 'AND c.offering_id = ?' : ''} + ${includeArchived ? '' : "AND c.status != 'archived'"} + ORDER BY CASE c.status WHEN 'active' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END, + c.created_at DESC`, + projectId ? [actor.workspaceId, projectId] : [actor.workspaceId], + ); + + const campaigns = []; + for (const row of rows) { + campaigns.push(campaignView(row, await campaignStats(db, actor.workspaceId, row.id))); + } + return c.json({ campaigns }); + }); + + // Registered before `/campaigns/:id` so "import" is never read as an id. + r.post('/campaigns/import', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'importing leads'); + await deps.requireVerifiedEmail(db, actor); + + const body = await parse(c.req.raw, importBody); + if (body.autopilot) await deps.requireVerifiedEmail(db, actor); + + let offering; + try { + offering = await ensureOffering(db, actor.workspaceId, body.project_id); + } catch (error) { + if (error instanceof UnknownProductError) throw ApiError.notFound('project'); + throw error; + } + + const campaignId = newId('campaign'); + const stamp = now(); + + await db.execute({ + sql: `INSERT INTO campaigns (id, workspace_id, name, offering_id, voice_profile_id, brief, + networks, approval_mode, status, seed_kind, seed_value, created_at, updated_at, + started_at) + VALUES (?, ?, ?, ?, ?, ?, '["email"]', ?, 'active', 'import', ?, ?, ?, ?)`, + args: [ + campaignId, + actor.workspaceId, + body.name, + offering.id, + offering.voiceProfileId, + body.instructions ?? null, + body.autopilot ? 'trusted_automation' : 'draft_and_approve', + `${body.leads.length} imported leads`, + stamp, + stamp, + stamp, + ], + }); + await inheritFilters(db, campaignId, actor.workspaceId, offering.id, stamp); + + const importId = await startContactImport(db, { + workspaceId: actor.workspaceId, + userId: actor.userId, + campaignId, + filename: `autogtm:${body.name}`, + consentBasis: body.consent_basis ?? 'opt_in', + ...(body.consent_source ? { consentSource: body.consent_source } : {}), + }); + + let imported = 0; + let merged = 0; + let rejected = 0; + const personIds = new Set(); + + for (let offset = 0; offset < body.leads.length; offset += IMPORT_CHUNK) { + const rows = body.leads.slice(offset, offset + IMPORT_CHUNK).map((lead) => ({ + email: lead.email, + ...(lead.first_name ? { firstName: lead.first_name } : {}), + ...(lead.last_name ? { lastName: lead.last_name } : {}), + ...(lead.company ? { company: lead.company } : {}), + ...(lead.job_title ? { title: lead.job_title } : {}), + ...(lead.location ? { location: lead.location } : {}), + })); + + const result = await importContactChunk(db, importId, rows, { startRow: offset }); + imported += result.imported; + merged += result.merged; + rejected += result.rejected; + for (const id of result.personIds) personIds.add(id); + } + + // The importer makes people; a campaign is a membership. Without this the + // leads exist and the campaign is empty, which is the bug the old + // `/contacts/imports` path shipped with. + for (const personId of personIds) { + const inserted = await db.execute({ + sql: `INSERT OR IGNORE INTO campaign_people (campaign_id, person_id, workspace_id, status, + interaction_state, discovered_at, updated_at) + VALUES (?, ?, ?, 'discovered', 'never_contacted', ?, ?)`, + args: [campaignId, personId, actor.workspaceId, stamp, stamp], + }); + if (inserted.rowsAffected > 0) { + await recordDiscovered(db, { workspaceId: actor.workspaceId, campaignId, personId }); + } + } + + // Research is what makes a lead sendable: every message is grounded in + // something read about them, so their company site is queued under this + // campaign. One crawl per domain however many colleagues were listed. + const domains = new Set(); + for (const lead of body.leads) { + const domain = lead.company_domain + ? normaliseDomain(lead.company_domain) + : normaliseDomain(lead.email.split('@')[1] ?? ''); + if (domain && !FREEMAIL.has(domain)) domains.add(domain); + } + let crawls = 0; + for (const domain of domains) { + const url = `https://${domain}`; + const queued = await enqueue(db, { + workspaceId: actor.workspaceId, + kind: 'crawl_site', + payload: { url, campaignId }, + dedupeKey: crawlDedupeKey(url), + }); + if (queued.queued) crawls += 1; + } + + await finishContactImport(db, importId); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'autogtm.campaign_imported', + entityKind: 'campaign', + entityId: campaignId, + detail: { importId, imported, merged, rejected, crawls }, + }); + + return c.json( + { + task_id: importId, + campaign_id: campaignId, + project_id: offering.id, + status: 'completed', + imported, + merged, + rejected, + crawls_queued: crawls, + }, + 201, + ); + }); + + r.get('/campaigns/import/:task_id', async (c) => { + const actor = c.get('actor'); + const row = await queryOne<{ + id: string; + campaign_id: string | null; + status: string; + total_rows: number; + imported: number; + merged: number; + rejected: number; + created_at: string; + updated_at: string; + }>( + c.get('db'), + `SELECT id, campaign_id, status, total_rows, imported, merged, rejected, created_at, updated_at + FROM contact_imports WHERE id = ? AND workspace_id = ?`, + [c.req.param('task_id'), actor.workspaceId], + ); + if (!row) throw ApiError.notFound('import'); + + return c.json({ + task_id: row.id, + campaign_id: row.campaign_id, + status: + row.status === 'complete' ? 'completed' : row.status === 'failed' ? 'failed' : 'pending', + total_rows: row.total_rows, + imported: row.imported, + merged: row.merged, + rejected: row.rejected, + created_at: row.created_at, + updated_at: row.updated_at, + }); + }); + + r.get('/campaigns/:id', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const [stats, targeting] = await Promise.all([ + campaignStats(db, actor.workspaceId, row.id), + targetingFor(db, row.id), + ]); + + return c.json({ + campaign: { + ...campaignView(row, stats), + instructions: row.brief, + targeting, + targeting_editable: true, + }, + }); + }); + + r.patch('/campaigns/:id', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'changing a campaign'); + + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const body = await parse(c.req.raw, campaignPatchBody); + const changed: Record = {}; + + if (body.name !== undefined) { + await db.execute({ + sql: 'UPDATE campaigns SET name = ?, updated_at = ? WHERE id = ?', + args: [body.name, now(), row.id], + }); + changed.name = body.name; + } + + if (body.instructions !== undefined) { + await db.execute({ + sql: 'UPDATE campaigns SET brief = ?, updated_at = ? WHERE id = ?', + args: [body.instructions || null, now(), row.id], + }); + changed.instructions = body.instructions; + } + + if (body.targeting !== undefined) { + await saveTargeting(db, row.id, body.targeting); + changed.targeting = await targetingFor(db, row.id); + } + + if (body.daily_limit_usd !== undefined) { + if (row.project_autopilot === 1) autopilotOwnsIt(row); + const budget = await setCampaignDailyBudget( + db, + actor.workspaceId, + row.id, + body.daily_limit_usd, + ); + await applyProjectBudget(db, actor.workspaceId, row.offering_id); + changed.daily_limit_usd = budget?.dailyBudgetUsd ?? null; + } + + if (Object.keys(changed).length === 0) { + throw ApiError.badRequest( + 'send at least one of name, instructions, targeting or daily_limit_usd', + ); + } + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'autogtm.campaign_changed', + entityKind: 'campaign', + entityId: row.id, + detail: { fields: Object.keys(changed) }, + }); + + return c.json({ campaign_id: row.id, ...changed }); + }); + + r.post('/campaigns/:id/start', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'starting a campaign'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + if (row.project_autopilot === 1) autopilotOwnsIt(row); + if (row.status === 'archived') throw ApiError.badRequest('an archived campaign cannot start'); + + await setCampaignStatus(db, actor.workspaceId, row.id, 'active'); + return c.json({ campaign_id: row.id, status: 'outreach', raw_status: 'active' }); + }); + + r.post('/campaigns/:id/stop', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'stopping a campaign'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + if (row.project_autopilot === 1) autopilotOwnsIt(row); + if (row.status === 'archived') throw ApiError.badRequest('an archived campaign cannot stop'); + + await setCampaignStatus(db, actor.workspaceId, row.id, 'paused'); + return c.json({ campaign_id: row.id, status: 'listening', raw_status: 'paused' }); + }); + + r.get('/campaigns/:id/budget', async (c) => { + const actor = c.get('actor'); + const row = await ownedCampaign(c.get('db'), actor.workspaceId, c.req.param('id')); + const budget = campaignBudgetFrom(row.budget_json); + return c.json({ + campaign_id: row.id, + daily_limit_usd: budget.dailyBudgetUsd ?? null, + max_contacts_per_day: budget.maxActionsPerDay ?? null, + price_per_contact_usd: CONTACT_PRICE_USD, + managed_by_autopilot: row.project_autopilot === 1, + }); + }); + + r.patch('/campaigns/:id/budget', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'changing a budget'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + if (row.project_autopilot === 1) autopilotOwnsIt(row); + + const body = await parse(c.req.raw, campaignBudgetBody); + const budget = await setCampaignDailyBudget( + db, + actor.workspaceId, + row.id, + body.daily_limit_usd, + ); + // The project ceiling still applies to hand-set budgets. + await applyProjectBudget(db, actor.workspaceId, row.offering_id); + const after = await ownedCampaign(db, actor.workspaceId, row.id); + const stored = campaignBudgetFrom(after.budget_json); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'campaign.limits_changed', + entityKind: 'campaign', + entityId: row.id, + detail: { + dailyBudgetUsd: budget?.dailyBudgetUsd ?? null, + maxActionsPerDay: stored.maxActionsPerDay, + }, + }); + + return c.json({ + campaign_id: row.id, + daily_limit_usd: stored.dailyBudgetUsd ?? null, + max_contacts_per_day: stored.maxActionsPerDay ?? null, + }); + }); + + r.get('/campaigns/:id/analytics', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const since = c.req.query('since'); + const stats = await campaignStats(db, actor.workspaceId, row.id, since); + return c.json({ + campaign_id: row.id, + status: autogtmStatus({ ...row, contacted: stats.contacted }), + ...(since ? { since } : {}), + ...analyticsFrom(stats), + }); + }); + + // ----------------------------------------------------------------- inbox + + r.get('/campaigns/:id/inbox', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const tab = c.req.query('tab') ?? 'all'; + const limit = clampPage(c.req.query('limit')); + const before = c.req.query('before'); + + if (!['all', 'need_reply', 'replied', 'sent', 'unsubscribed'].includes(tab)) { + throw ApiError.badRequest('tab must be one of all, need_reply, replied, sent, unsubscribed'); + } + + const conversations = await queryAll<{ + person_id: string; + display_name: string; + current_title: string | null; + company_name: string | null; + company_domain: string | null; + lead_status: string; + note: string | null; + inbound: number; + outbound: number; + last_at: string; + last_direction: string; + last_body: string | null; + suppressed: number; + }>( + db, + `SELECT p.id AS person_id, p.display_name, p.current_title, + co.name AS company_name, co.domain AS company_domain, + cp.status AS lead_status, cp.note, + t.inbound, t.outbound, t.last_at, + (SELECT i.direction FROM interactions i + WHERE i.workspace_id = ? AND i.person_id = p.id + ORDER BY i.occurred_at DESC LIMIT 1) AS last_direction, + (SELECT i.body FROM interactions i + WHERE i.workspace_id = ? AND i.person_id = p.id + ORDER BY i.occurred_at DESC LIMIT 1) AS last_body, + (SELECT COUNT(*) FROM suppression_keys sk + WHERE sk.match_key = 'person:' || p.id + AND (sk.scope = 'global' OR sk.workspace_id = ?)) AS suppressed + FROM campaign_people cp + JOIN people p ON p.id = cp.person_id + LEFT JOIN companies co ON co.id = p.current_company_id + JOIN (SELECT i.person_id, + SUM(CASE WHEN i.direction = 'inbound' THEN 1 ELSE 0 END) AS inbound, + SUM(CASE WHEN i.direction = 'outbound' THEN 1 ELSE 0 END) AS outbound, + MAX(i.occurred_at) AS last_at + FROM interactions i WHERE i.workspace_id = ? GROUP BY i.person_id) t + ON t.person_id = cp.person_id + WHERE cp.campaign_id = ? AND p.status != 'deleted' + ${before ? 'AND t.last_at < ?' : ''} + ORDER BY t.last_at DESC + LIMIT ?`, + [ + actor.workspaceId, + actor.workspaceId, + actor.workspaceId, + actor.workspaceId, + row.id, + ...(before ? [before] : []), + // Over-fetch so a tab filter still fills a page. + limit * 4, + ], + ); + + const tabbed = conversations + .map((conv) => { + const status = + Number(conv.suppressed) > 0 + ? 'unsubscribed' + : conv.last_direction === 'inbound' + ? 'need_reply' + : Number(conv.inbound) > 0 + ? 'replied' + : 'sent'; + return { + person_id: conv.person_id, + name: conv.display_name, + job_title: conv.current_title, + company: conv.company_name, + company_domain: conv.company_domain, + status, + lead_status: conv.lead_status, + messages: { inbound: Number(conv.inbound), outbound: Number(conv.outbound) }, + last_message_at: conv.last_at, + last_message_from: conv.last_direction === 'inbound' ? 'lead' : 'you', + last_message_preview: conv.last_body ? conv.last_body.slice(0, 200) : null, + note: conv.note, + }; + }) + .filter((conv) => tab === 'all' || conv.status === tab) + .slice(0, limit); + + const last = tabbed[tabbed.length - 1]; + return c.json({ + campaign_id: row.id, + tab, + conversations: tabbed, + ...(tabbed.length === limit && last ? { next_before: last.last_message_at } : {}), + }); + }); + + r.get('/campaigns/:id/inbox/:person_id', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const personId = c.req.param('person_id'); + const member = await leadInCampaign(db, row.id, personId); + if (!member) throw ApiError.notFound('lead'); + + const messages = await queryAll<{ + id: string; + direction: string; + network: string; + state: string; + body: string | null; + subject: string | null; + contact_address: string | null; + occurred_at: string; + }>( + db, + `SELECT i.id, i.direction, i.network, i.state, i.body, i.contact_address, i.occurred_at, + (SELECT d.subject FROM actions a + JOIN drafts d ON d.recommendation_id = a.recommendation_id + WHERE a.id = i.action_id LIMIT 1) AS subject + FROM interactions i + WHERE i.workspace_id = ? AND i.person_id = ? + ORDER BY i.occurred_at ASC`, + [actor.workspaceId, personId], + ); + + return c.json({ + campaign_id: row.id, + lead: member, + messages: messages.map((m) => ({ + id: m.id, + from: m.direction === 'inbound' ? 'lead' : 'you', + network: m.network, + state: m.state, + subject: m.subject, + body: m.body, + address: m.contact_address, + at: m.occurred_at, + })), + }); + }); + + r.post('/campaigns/:id/inbox/:person_id/reply', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'replying to a lead'); + await deps.requireVerifiedEmail(db, actor); + + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const personId = c.req.param('person_id'); + const member = await leadInCampaign(db, row.id, personId); + if (!member) throw ApiError.notFound('lead'); + + const body = await parse(c.req.raw, replyBody); + + // Threading: the subject of the last thing we sent them, prefixed. + const lastSubject = await queryOne<{ subject: string | null }>( + db, + `SELECT d.subject FROM interactions i + JOIN actions a ON a.id = i.action_id + JOIN drafts d ON d.recommendation_id = a.recommendation_id + WHERE i.workspace_id = ? AND i.person_id = ? AND i.direction = 'outbound' + ORDER BY i.occurred_at DESC LIMIT 1`, + [actor.workspaceId, personId], + ); + const subjectBase = lastSubject?.subject?.trim() || `Following up`; + const subject = /^re:/i.test(subjectBase) ? subjectBase : `Re: ${subjectBase}`; + + const recommendationId = newId('recommendation'); + const draftId = newId('draft'); + const stamp = now(); + + await db.batch([ + { + // `send_email` because that is the one email capability the matrix + // describes; the goal is what marks it as an answer rather than a + // fresh approach, and what the policy recheck reads as a follow-up. + sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action, network, + priority, reason, trigger_signal_id, policy_status, policy_version, expected_goal, + status, created_at) + VALUES (?, ?, ?, ?, 'send_email', 'email', 100, 'Reply written through the API', NULL, + 'allow_with_approval', ?, 'continue_conversation', 'pending', ?)`, + args: [recommendationId, actor.workspaceId, row.id, personId, POLICY_VERSION, stamp], + }, + { + sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, subject, body, + grounded_signal_ids, checks_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, '[]', '[]', ?, ?)`, + args: [draftId, actor.workspaceId, recommendationId, subject, body.text, stamp, stamp], + }, + ]); + + const recommendation = await repo.getRecommendation(db, actor.workspaceId, recommendationId); + if (!recommendation) throw new Error('reply recommendation vanished'); + + const outcome = await deps.approve(db, actor, recommendation); + + if (!outcome.ok) { + // Retired rather than left pending: a refused answer must not sit in + // the queue as a card somebody, or something, later approves. + await db.execute({ + sql: `UPDATE recommendations SET status = 'skipped' WHERE id = ?`, + args: [recommendationId], + }); + throw ApiError.policyDenied(outcome.reason ?? 'refused by policy', { + decision: outcome.decision, + gate: outcome.gate, + }); + } + + if (outcome.delivery && !outcome.delivery.sent) { + return c.json( + { + sent: false, + campaign_id: row.id, + person_id: personId, + action_id: outcome.actionId, + reason: outcome.delivery.reason, + }, + 502, + ); + } + + return c.json({ + sent: outcome.delivery?.sent === true, + campaign_id: row.id, + person_id: personId, + action_id: outcome.actionId, + subject, + ...(outcome.delivery?.to ? { to: outcome.delivery.to } : {}), + ...(outcome.delivery + ? {} + : { note: 'recorded for a human to send: this deployment cannot put email on the wire' }), + }); + }); + + r.get('/campaigns/:id/inbox/:person_id/note', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const personId = c.req.param('person_id'); + const member = await leadInCampaign(db, row.id, personId); + if (!member) throw ApiError.notFound('lead'); + return c.json({ campaign_id: row.id, person_id: personId, note: member.note }); + }); + + r.post('/campaigns/:id/inbox/:person_id/note', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'writing a note'); + const row = await ownedCampaign(db, actor.workspaceId, c.req.param('id')); + const personId = c.req.param('person_id'); + const member = await leadInCampaign(db, row.id, personId); + if (!member) throw ApiError.notFound('lead'); + + const body = await parse(c.req.raw, noteBody); + const note = body.note?.trim() || null; + await db.execute({ + sql: `UPDATE campaign_people SET note = ?, updated_at = ? WHERE campaign_id = ? AND person_id = ?`, + args: [note, now(), row.id, personId], + }); + return c.json({ campaign_id: row.id, person_id: personId, note }); + }); + + r.get('/hot-leads', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + const since = c.req.query('since'); + const limit = clampPage(c.req.query('limit')); + + const rows = await queryAll<{ + person_id: string; + campaign_id: string; + campaign_name: string; + display_name: string; + current_title: string | null; + company_name: string | null; + company_domain: string | null; + lead_status: string; + note: string | null; + replied_at: string | null; + last_reply: string | null; + }>( + db, + `SELECT cp.person_id, cp.campaign_id, c.name AS campaign_name, + p.display_name, p.current_title, co.name AS company_name, co.domain AS company_domain, + cp.status AS lead_status, cp.note, + (SELECT MAX(i.occurred_at) FROM interactions i + WHERE i.workspace_id = ? AND i.person_id = cp.person_id + AND i.direction = 'inbound') AS replied_at, + (SELECT i.body FROM interactions i + WHERE i.workspace_id = ? AND i.person_id = cp.person_id + AND i.direction = 'inbound' + ORDER BY i.occurred_at DESC LIMIT 1) AS last_reply + FROM campaign_people cp + JOIN campaigns c ON c.id = cp.campaign_id + JOIN people p ON p.id = cp.person_id + LEFT JOIN companies co ON co.id = p.current_company_id + WHERE cp.workspace_id = ? AND p.status != 'deleted' + AND (cp.status IN ('responded', 'qualified_opportunity') + OR cp.interaction_state = 'responded') + ${since ? 'AND replied_at >= ?' : ''} + ORDER BY replied_at DESC + LIMIT ?`, + [actor.workspaceId, actor.workspaceId, actor.workspaceId, ...(since ? [since] : []), limit], + ); + + return c.json({ + ...(since ? { since } : {}), + hot_leads: rows.map((lead) => ({ + person_id: lead.person_id, + campaign_id: lead.campaign_id, + campaign_name: lead.campaign_name, + name: lead.display_name, + job_title: lead.current_title, + company: lead.company_name, + company_domain: lead.company_domain, + lead_status: lead.lead_status, + replied_at: lead.replied_at, + last_reply_preview: lead.last_reply ? lead.last_reply.slice(0, 300) : null, + note: lead.note, + })), + polled_at: now(), + }); + }); + + // -------------------------------------------------------- suppress lists + + r.get('/suppress-list/people', async (c) => { + const actor = c.get('actor'); + return c.json({ lists: await listSuppressLists(c.get('db'), actor.workspaceId, 'person') }); + }); + + r.get('/suppress-list/companies', async (c) => { + const actor = c.get('actor'); + return c.json({ lists: await listSuppressLists(c.get('db'), actor.workspaceId, 'company') }); + }); + + r.post('/suppress-list/people', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'suppressing people'); + const body = await parse(c.req.raw, suppressPeopleBody); + const keys = [...new Set(body.emails.map(emailMatchKey))]; + const result = await addToSuppressList(db, actor, { + kind: 'person', + name: body.list_name, + reason: body.reason ?? 'do_not_contact', + keys, + }); + return c.json({ list_name: body.list_name, kind: 'person', ...result }, 201); + }); + + r.post('/suppress-list/companies', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + requireWriter(actor, 'suppressing companies'); + const body = await parse(c.req.raw, suppressCompaniesBody); + const keys = [...new Set(body.domains.map(domainMatchKey))].filter((k) => k !== 'domain:'); + if (keys.length === 0) throw ApiError.badRequest('no usable domains'); + const result = await addToSuppressList(db, actor, { + kind: 'company', + name: body.list_name, + reason: body.reason ?? 'do_not_contact', + keys, + }); + return c.json({ list_name: body.list_name, kind: 'company', ...result }, 201); + }); + + r.get('/suppress-list/people/:name', async (c) => { + const actor = c.get('actor'); + const list = await readSuppressList( + c.get('db'), + actor.workspaceId, + 'person', + c.req.param('name'), + ); + if (!list) throw ApiError.notFound('list'); + return c.json({ + list_name: list.name, + kind: 'person', + emails: list.values, + added_at: list.addedAt, + }); + }); + + r.get('/suppress-list/companies/:name', async (c) => { + const actor = c.get('actor'); + const list = await readSuppressList( + c.get('db'), + actor.workspaceId, + 'company', + c.req.param('name'), + ); + if (!list) throw ApiError.notFound('list'); + return c.json({ + list_name: list.name, + kind: 'company', + domains: list.values, + added_at: list.addedAt, + }); + }); + + r.delete('/suppress-list/people/:name', async (c) => { + const actor = c.get('actor'); + requireWriter(actor, 'deleting a suppress list'); + const removed = await deleteSuppressList(c.get('db'), actor, 'person', c.req.param('name')); + if (!removed) throw ApiError.notFound('list'); + return c.json({ deleted: true, list_name: c.req.param('name'), kind: 'person' }); + }); + + r.delete('/suppress-list/companies/:name', async (c) => { + const actor = c.get('actor'); + requireWriter(actor, 'deleting a suppress list'); + const removed = await deleteSuppressList(c.get('db'), actor, 'company', c.req.param('name')); + if (!removed) throw ApiError.notFound('list'); + return c.json({ deleted: true, list_name: c.req.param('name'), kind: 'company' }); + }); + + // --------------------------------------------------------------- billing + + r.get('/billing/balance', async (c) => { + const actor = c.get('actor'); + const status = await budgetStatus(c.get('db'), actor.workspaceId); + return c.json({ + plan: { + id: status.plan.id, + name: status.plan.name, + contacts_per_month: status.plan.prospectsPerMonth, + }, + this_month: { + contacts_used: status.usage.prospectsContacted, + contacts_remaining: Math.max( + 0, + status.plan.prospectsPerMonth - status.usage.prospectsContacted, + ), + }, + credits: { + remaining: status.credits.remaining, + granted: status.credits.granted, + spent: status.credits.spent, + price_per_contact_usd: CONTACT_PRICE_USD, + }, + on_credits: status.onCredits, + exhausted: status.exhausted, + ...(status.reason ? { reason: status.reason } : {}), + }); + }); + + return r; +} + +// ----------------------------------------------------------------- helpers + +/** Addresses that name a mailbox provider, not a company worth crawling. */ +const FREEMAIL = new Set([ + 'gmail.com', + 'googlemail.com', + 'yahoo.com', + 'hotmail.com', + 'outlook.com', + 'live.com', + 'icloud.com', + 'me.com', + 'aol.com', + 'protonmail.com', + 'proton.me', + 'mail.com', + 'gmx.com', + 'yandex.com', +]); + +interface LeadMember { + readonly person_id: string; + readonly name: string; + readonly job_title: string | null; + readonly company: string | null; + readonly company_domain: string | null; + readonly lead_status: string; + readonly note: string | null; +} + +async function leadInCampaign( + db: Client, + campaignId: string, + personId: string, +): Promise { + const row = await queryOne<{ + person_id: string; + display_name: string; + current_title: string | null; + company_name: string | null; + company_domain: string | null; + status: string; + note: string | null; + }>( + db, + `SELECT cp.person_id, p.display_name, p.current_title, co.name AS company_name, + co.domain AS company_domain, cp.status, cp.note + FROM campaign_people cp + JOIN people p ON p.id = cp.person_id + LEFT JOIN companies co ON co.id = p.current_company_id + WHERE cp.campaign_id = ? AND cp.person_id = ? AND p.status != 'deleted'`, + [campaignId, personId], + ); + if (!row) return undefined; + return { + person_id: row.person_id, + name: row.display_name, + job_title: row.current_title, + company: row.company_name, + company_domain: row.company_domain, + lead_status: row.status, + note: row.note, + }; +} + +type ListKind = 'person' | 'company'; + +async function listSuppressLists(db: Client, workspaceId: string, kind: ListKind) { + const rows = await queryAll<{ name: string; entries: number; created_at: string }>( + db, + `SELECT e.name, COUNT(k.match_key) AS entries, MIN(e.created_at) AS created_at + FROM suppression_entries e + LEFT JOIN suppression_keys k ON k.suppression_id = e.id + WHERE e.workspace_id = ? AND e.kind = ? AND e.name IS NOT NULL + AND (k.match_key IS NULL OR k.match_key NOT LIKE 'person:%') + GROUP BY e.name ORDER BY MIN(e.created_at)`, + [workspaceId, kind], + ); + return rows.map((row) => ({ + list_name: row.name, + kind, + entries: Number(row.entries), + created_at: row.created_at, + })); +} + +async function readSuppressList(db: Client, workspaceId: string, kind: ListKind, name: string) { + const rows = await queryAll<{ match_key: string; created_at: string }>( + db, + `SELECT k.match_key, e.created_at + FROM suppression_entries e + JOIN suppression_keys k ON k.suppression_id = e.id + WHERE e.workspace_id = ? AND e.kind = ? AND e.name = ? + AND k.match_key NOT LIKE 'person:%' + ORDER BY e.created_at, k.match_key`, + [workspaceId, kind, name], + ); + if (rows.length === 0) return undefined; + const prefix = kind === 'person' ? 'email:' : 'domain:'; + return { + name, + values: rows.map((row) => row.match_key.slice(prefix.length)), + addedAt: rows[0]?.created_at ?? null, + }; +} + +async function addToSuppressList( + db: Client, + actor: RequestActor, + input: { kind: ListKind; name: string; reason: string; keys: readonly string[] }, +): Promise<{ added: number; people_halted: number }> { + // People already on file who match get a `person:` key as well, so a later + // edit to their address does not un-suppress them, and their queued cards + // are cleared today. + const matched = await peopleMatchingKeys(db, actor.workspaceId, input.keys); + const id = newId('suppression'); + const stamp = now(); + + await db.batch([ + { + sql: `INSERT INTO suppression_entries (id, reason, scope, workspace_id, source, created_at, + name, kind) + VALUES (?, ?, 'workspace', ?, 'api', ?, ?, ?)`, + args: [id, input.reason, actor.workspaceId, stamp, input.name, input.kind], + }, + ...[...input.keys, ...matched.map((personId) => `person:${personId}`)].map((key) => ({ + sql: `INSERT OR IGNORE INTO suppression_keys (match_key, suppression_id, scope, workspace_id) + VALUES (?, ?, 'workspace', ?)`, + args: [key, id, actor.workspaceId], + })), + ...matched.map((personId) => ({ + sql: `UPDATE recommendations SET status = 'skipped' + WHERE workspace_id = ? AND person_id = ? AND status IN ('pending', 'approved')`, + args: [actor.workspaceId, personId], + })), + ]); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'suppression.created', + entityKind: 'suppression', + entityId: id, + detail: { list: input.name, kind: input.kind, keys: input.keys.length, halted: matched.length }, + }); + + return { added: input.keys.length, people_halted: matched.length }; +} + +async function deleteSuppressList( + db: Client, + actor: RequestActor, + kind: ListKind, + name: string, +): Promise { + const result = await db.execute({ + sql: `DELETE FROM suppression_entries WHERE workspace_id = ? AND kind = ? AND name = ?`, + args: [actor.workspaceId, kind, name], + }); + if (result.rowsAffected === 0) return false; + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'suppression.list_deleted', + entityKind: 'suppression', + entityId: name, + detail: { kind, entries: result.rowsAffected }, + }); + return true; +} diff --git a/apps/api/src/campaigns.ts b/apps/api/src/campaigns.ts index 782a911..55cb4a9 100644 --- a/apps/api/src/campaigns.ts +++ b/apps/api/src/campaigns.ts @@ -330,7 +330,7 @@ interface EnsuredOffering { * is kept for callers that genuinely have no opinion — a workspace with one * product, which is most of them. */ -async function ensureOffering( +export async function ensureOffering( db: Client, workspaceId: string, offeringId?: string, @@ -399,7 +399,7 @@ async function ensureOffering( * A product with no filters row yet — one that has never been through setup — * simply leaves the new campaign without one, exactly as before. */ -async function inheritFilters( +export async function inheritFilters( db: Client, campaignId: string, workspaceId: string, diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index cbb2200..663ecab 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -14,6 +14,12 @@ export interface RequestActor { readonly workspaceId: string; readonly organizationId: string; readonly role: 'owner' | 'admin' | 'member' | 'viewer'; + /** + * What the caller presented. Absent means a session, which is what every + * caller was before API keys existed. A key may do what its owner may do, + * except mint more keys — that stays with the person who signs in. + */ + readonly credential?: 'session' | 'api_key' | 'service'; } export interface AppEnv { diff --git a/apps/api/src/repository.ts b/apps/api/src/repository.ts index 9b3cb9b..e0f0f75 100644 --- a/apps/api/src/repository.ts +++ b/apps/api/src/repository.ts @@ -19,6 +19,7 @@ import { type Network, } from '@outreachgraph/domain'; import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { matchKeysForPerson } from '@outreachgraph/pipeline'; export interface PersonRow { id: string; @@ -695,19 +696,7 @@ export async function isSuppressed( /** Match keys for a person, used for suppression lookup. */ export async function suppressionKeysForPerson(db: Client, personId: string): Promise { - const identities = await queryAll<{ network: string; platform_user_id: string | null }>( - db, - 'SELECT network, platform_user_id FROM social_identities WHERE person_id = ?', - [personId], - ); - - const keys = [`person:${personId}`]; - for (const identity of identities) { - if (identity.platform_user_id) { - keys.push(`platform:${identity.network}:${identity.platform_user_id}`); - } - } - return keys; + return matchKeysForPerson(db, personId); } export async function hasConnectedAccount( diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e144805..7d103f6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,7 @@ import { rescoreProspect, reseedIdleCampaigns, runAutopilot, + applyProjectBudgets, sweepProfilePhotos, workspacesAwaitingPhotos, runCadences, @@ -928,6 +929,17 @@ async function tick(): Promise { console.error(`cadences failed for ${workspace.id}`, error); } + try { + // Project ceilings become campaign caps before the sender reads them, + // so a budget set a minute ago is the budget this tick honours. + const budgets = await applyProjectBudgets(db, workspace.id); + if (budgets.changed > 0) { + console.log(`budgets ${workspace.id}: ${budgets.changed} campaign cap(s) reallocated`); + } + } catch (error) { + console.error(`budget allocation failed for ${workspace.id}`, error); + } + try { // The customer's own mail server when they have connected one, the // platform mailer otherwise. `runAutopilot` resolves which, once per diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 5a98165..77abdef 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -2,13 +2,16 @@ import { redirect } from 'next/navigation'; import { SettingsForm } from '../../../components/settings-form'; import { MailboxForm } from '../../../components/mailbox-form'; import { BlueskyForm } from '../../../components/bluesky-form'; +import { ApiKeysForm } from '../../../components/api-keys-form'; import { PageGuide } from '../../../components/page-guide'; import { ApiUnavailableError, NotAuthenticatedError, + fetchApiKeys, fetchBlueskyIntegration, fetchEmailIntegration, fetchSettings, + type ApiKeyView, type BlueskyIntegrationView, type SettingsView, } from '../../../lib/api'; @@ -30,13 +33,15 @@ export default async function SettingsPage() { let settings: SettingsView | undefined; let mailbox: EmailIntegrationView | undefined; let bluesky: BlueskyIntegrationView | undefined; + let apiKeys: readonly ApiKeyView[] = []; let offline = false; try { - [settings, mailbox, bluesky] = await Promise.all([ + [settings, mailbox, bluesky, apiKeys] = await Promise.all([ fetchSettings(), fetchEmailIntegration(), fetchBlueskyIntegration(), + fetchApiKeys(), ]); } catch (error) { if (error instanceof NotAuthenticatedError) redirect('/login'); @@ -69,6 +74,10 @@ export default async function SettingsPage() { {bluesky ? : null} + + {/* Last: for the agents that drive the product, once it has + something to drive. */} + )} diff --git a/apps/web/components/api-keys-form.tsx b/apps/web/components/api-keys-form.tsx new file mode 100644 index 0000000..a69310c --- /dev/null +++ b/apps/web/components/api-keys-form.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, type FormEvent } from 'react'; +import type { ApiKeyView } from '../lib/api'; + +/** + * Keys for the agents that drive this workspace. + * + * The secret is shown once, here, the moment it is minted, and never again: + * the API stores a hash and cannot read it back. Everything else on the list + * is a prefix, a name and a last-used time — enough to know which key an + * agent is holding and which to revoke, without the page ever being a place + * to copy a live credential from. + */ +export function ApiKeysForm({ initial }: { initial: readonly ApiKeyView[] }) { + const router = useRouter(); + const [name, setName] = useState(''); + const [busy, setBusy] = useState<'mint' | string | undefined>(); + const [error, setError] = useState(); + const [fresh, setFresh] = useState<{ name: string; key: string } | undefined>(); + + async function mint(event: FormEvent): Promise { + event.preventDefault(); + setBusy('mint'); + setError(undefined); + + try { + const response = await fetch('/api/v1/api-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ name }), + }); + const payload = (await response.json().catch(() => ({}))) as { + key?: { name: string; key: string }; + error?: { message?: string }; + }; + + if (!response.ok || !payload.key) { + setError(payload.error?.message ?? `that failed (${response.status})`); + return; + } + + setFresh(payload.key); + setName(''); + router.refresh(); + } catch { + setError('could not reach the server'); + } finally { + setBusy(undefined); + } + } + + async function revoke(id: string, label: string): Promise { + if (!confirm(`Revoke “${label}”? Anything using it stops working now.`)) return; + + setBusy(id); + setError(undefined); + + try { + const response = await fetch(`/api/v1/api-keys/${id}`, { + method: 'DELETE', + credentials: 'same-origin', + }); + if (!response.ok) { + setError(`could not revoke (${response.status})`); + return; + } + if (fresh && initial.find((k) => k.id === id)?.name === fresh.name) setFresh(undefined); + router.refresh(); + } catch { + setError('could not reach the server'); + } finally { + setBusy(undefined); + } + } + + return ( +
+

API keys

+

+ For agents that run outreach through the API. Read{' '} + + llms.txt + {' '} + or the{' '} + + OpenAPI schema + + . Send a key as X-API-Key on every request. +

+ + {fresh ? ( +
+

+ “{fresh.name}” is ready. Copy it now; it is not shown again. +

+ + {fresh.key} + +
+ ) : null} + +
    + {initial.length === 0 ? ( +
  • + No keys yet. +
  • + ) : null} + {initial.map((key) => ( +
  • +
    +

    {key.name}

    +

    + {key.prefix}… + {key.lastUsedAt + ? ` · last used ${new Date(key.lastUsedAt).toLocaleString()}` + : ' · never used'} +

    +
    + +
  • + ))} +
+ +
+ + +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 346a415..92d0e3e 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -317,6 +317,20 @@ export async function fetchEmailIntegration(): Promise { return request('/integrations/email'); } +export interface ApiKeyView { + readonly id: string; + readonly name: string; + /** The first characters of the secret; the rest is never returned. */ + readonly prefix: string; + readonly createdAt: string; + readonly lastUsedAt: string | null; +} + +export async function fetchApiKeys(): Promise { + const body = await request<{ keys: readonly ApiKeyView[] }>('/api-keys'); + return body.keys; +} + export interface CreditPackView { readonly id: string; readonly name: string; diff --git a/migrations/0038_autogtm.sql b/migrations/0038_autogtm.sql new file mode 100644 index 0000000..021ef19 --- /dev/null +++ b/migrations/0038_autogtm.sql @@ -0,0 +1,54 @@ +-- 0038_autogtm.sql +-- +-- The AutoGTM surface: a public API shaped like the one agents already know +-- how to drive, backed by the campaigns, policy engine and credits that were +-- already here. +-- +-- Four additions, each the smallest thing that makes one part of that surface +-- honest rather than pretend: +-- +-- - `api_keys`: a credential that belongs to a workspace, so a customer can +-- hand an agent a key without handing it the process-wide service token +-- and two scope headers. Stored hashed, shown once, revocable. +-- - Project budget and autopilot on `offerings`: a "project" is a product, +-- and Explee-style control is a daily dollar ceiling per project that the +-- allocator splits across its campaigns. `NULL` means no ceiling, which is +-- what every existing product has and must keep. +-- - `campaign_people.note`: the one place a human or an agent leaves a +-- sentence about a lead in the context of one campaign. +-- - Named suppress lists: `suppression_entries` grows a name and a kind, so +-- "the competitors list" is one row whose keys can be read back and +-- deleted together. The match-key vocabulary grows two spellings, +-- `email:
` and `domain:`, which the suppression checks now +-- read alongside `person:` and `platform:`. + +CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + -- Whose authority the key carries. Role is read from their membership at + -- request time, so removing someone from the organization kills their keys. + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + -- SHA-256 of the secret. The secret itself is returned once and never stored. + key_hash TEXT NOT NULL UNIQUE, + -- The first characters of the secret, so a list can say which key is which. + key_prefix TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX idx_api_keys_ws ON api_keys(workspace_id, created_at DESC); + +ALTER TABLE offerings ADD COLUMN daily_budget_usd REAL; +ALTER TABLE offerings ADD COLUMN autopilot INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE campaign_people ADD COLUMN note TEXT; + +ALTER TABLE suppression_entries ADD COLUMN name TEXT; +-- person | company. Null for entries written before lists existed. +ALTER TABLE suppression_entries ADD COLUMN kind TEXT; + +CREATE INDEX idx_suppression_entries_list + ON suppression_entries(workspace_id, kind, name); diff --git a/packages/domain/src/autogtm.test.ts b/packages/domain/src/autogtm.test.ts new file mode 100644 index 0000000..eb76868 --- /dev/null +++ b/packages/domain/src/autogtm.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import { + allocateDailyBudget, + autogtmStatus, + capUnderCeiling, + CONTACT_PRICE_USD, + dailyContactsFor, + replyRate, + usdForContacts, +} from './autogtm'; + +describe('CONTACT_PRICE_USD', () => { + test('is the smallest pack price per credit', () => { + // pack_100 is $15 for 100 credits. + expect(CONTACT_PRICE_USD).toBe(0.15); + }); +}); + +describe('dailyContactsFor', () => { + test('floors to whole contacts', () => { + expect(dailyContactsFor(0.44)).toBe(2); + expect(dailyContactsFor(0.45)).toBe(3); + expect(dailyContactsFor(15)).toBe(100); + }); + + test('nothing, nonsense and negatives buy zero', () => { + expect(dailyContactsFor(0)).toBe(0); + expect(dailyContactsFor(-3)).toBe(0); + expect(dailyContactsFor(Number.NaN)).toBe(0); + }); + + test('round-trips through usdForContacts', () => { + expect(usdForContacts(dailyContactsFor(30))).toBe(30); + }); +}); + +describe('allocateDailyBudget', () => { + test('sums to the total exactly', () => { + const split = allocateDailyBudget({ + totalUsd: 10, + campaigns: [ + { id: 'a', contacted: 100, replies: 9 }, + { id: 'b', contacted: 100, replies: 1 }, + { id: 'c', contacted: 0, replies: 0 }, + ], + }); + + const sum = [...split.values()].reduce((acc, value) => acc + value, 0); + expect(Math.round(sum * 100) / 100).toBe(10); + }); + + test('a campaign that replies more gets more', () => { + const split = allocateDailyBudget({ + totalUsd: 10, + campaigns: [ + { id: 'strong', contacted: 100, replies: 9 }, + { id: 'weak', contacted: 100, replies: 1 }, + ], + }); + + expect(split.get('strong')!).toBeGreaterThan(split.get('weak')!); + }); + + test('a new campaign is not starved', () => { + const split = allocateDailyBudget({ + totalUsd: 10, + campaigns: [ + { id: 'proven', contacted: 1000, replies: 200 }, + { id: 'new', contacted: 0, replies: 0 }, + ], + }); + + // At least its share of the exploration slice. + expect(split.get('new')!).toBeGreaterThanOrEqual(1); + }); + + test('is deterministic', () => { + const input = { + totalUsd: 7.77, + campaigns: [ + { id: 'a', contacted: 3, replies: 1 }, + { id: 'b', contacted: 30, replies: 2 }, + ], + }; + expect([...allocateDailyBudget(input)]).toEqual([...allocateDailyBudget(input)]); + }); + + test('no campaigns or no money allocates nothing', () => { + expect(allocateDailyBudget({ totalUsd: 10, campaigns: [] }).size).toBe(0); + const zero = allocateDailyBudget({ + totalUsd: 0, + campaigns: [{ id: 'a', contacted: 1, replies: 1 }], + }); + expect(zero.get('a')).toBe(0); + }); +}); + +describe('capUnderCeiling', () => { + test('leaves budgets alone when they fit', () => { + const budgets = new Map([ + ['a', 3], + ['b', 4], + ]); + expect(capUnderCeiling(budgets, 10)).toBe(budgets); + }); + + test('scales proportionally when they do not', () => { + const capped = capUnderCeiling( + new Map([ + ['a', 30], + ['b', 10], + ]), + 20, + ); + expect(capped.get('a')).toBe(15); + expect(capped.get('b')).toBe(5); + }); +}); + +describe('autogtmStatus', () => { + test('maps every row shape somewhere sensible', () => { + expect(autogtmStatus({ status: 'archived', approval_mode: 'draft_and_approve' })).toBe( + 'archived', + ); + expect(autogtmStatus({ status: 'paused', approval_mode: 'trusted_automation' })).toBe( + 'listening', + ); + expect(autogtmStatus({ status: 'draft', approval_mode: 'draft_and_approve' })).toBe( + 'discovery', + ); + expect(autogtmStatus({ status: 'active', approval_mode: 'trusted_automation' })).toBe( + 'outreach', + ); + expect(autogtmStatus({ status: 'active', approval_mode: 'draft_and_approve' })).toBe('review'); + expect(autogtmStatus({ status: 'active', approval_mode: 'research_only', contacted: 0 })).toBe( + 'discovery', + ); + }); +}); + +describe('replyRate', () => { + test('is a fraction, zero before anything is sent', () => { + expect(replyRate(0, 0)).toBe(0); + expect(replyRate(200, 7)).toBe(0.035); + }); +}); diff --git a/packages/domain/src/autogtm.ts b/packages/domain/src/autogtm.ts new file mode 100644 index 0000000..53f03e4 --- /dev/null +++ b/packages/domain/src/autogtm.ts @@ -0,0 +1,175 @@ +/** + * The arithmetic behind the AutoGTM surface. + * + * Everything an agent can set through that API is a number of dollars per + * day, and everything the policy engine enforces is a number of actions per + * day. This module is the exchange rate between the two, plus the allocator + * that splits one project's dollars across its campaigns. Pure on purpose: it + * is what the API, the worker and the tests all agree on, and none of them + * should have to open a database to find out what a budget means. + */ + +import { CREDIT_PACKS } from './credits'; + +/** + * What one contacted prospect costs, in dollars. + * + * The list price of the smallest credit pack, per credit. Derived rather than + * typed so a pricing change moves the exchange rate with it — two numbers that + * are supposed to agree and live in two files eventually do not. + */ +export const CONTACT_PRICE_USD: number = (() => { + const smallest = [...CREDIT_PACKS].sort((a, b) => a.credits - b.credits)[0]; + if (!smallest) return 0.15; + return Math.round((smallest.priceUsd / smallest.credits) * 10_000) / 10_000; +})(); + +/** Dollars, rounded to the cent. */ +export function roundUsd(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * How many contacts a daily budget buys. + * + * Floored: a budget that covers 2.9 contacts covers 2, because the third one + * would be spent past the ceiling the customer set. Zero is a real answer and + * means "do not send", which the policy engine already honours as a cap of 0. + */ +export function dailyContactsFor(dailyBudgetUsd: number): number { + if (!Number.isFinite(dailyBudgetUsd) || dailyBudgetUsd <= 0) return 0; + return Math.floor(dailyBudgetUsd / CONTACT_PRICE_USD + 1e-9); +} + +/** The dollars a given number of contacts costs. */ +export function usdForContacts(contacts: number): number { + return roundUsd(Math.max(0, contacts) * CONTACT_PRICE_USD); +} + +export interface AllocationCandidate { + readonly id: string; + /** Outbound messages sent, over the window the caller chose. */ + readonly contacted: number; + /** Replies received over the same window. */ + readonly replies: number; +} + +export interface AllocationInput { + /** The project's daily ceiling. */ + readonly totalUsd: number; + readonly campaigns: readonly AllocationCandidate[]; + /** + * The share of the budget spread evenly regardless of performance, so a new + * campaign gets a chance to earn its keep. The rest follows reply rate. + */ + readonly explorationShare?: number; +} + +/** + * Splits one daily budget across campaigns by how well each one is replying. + * + * Two parts. An exploration slice is shared evenly, so a campaign with no + * history is not starved before it has had a chance to produce any. The rest + * is proportional to a smoothed reply rate — `(replies + 1) / (contacted + + * 10)` — which is a Laplace prior that reads a campaign with two replies from + * twenty as better than one with zero from two, and both as unproven. No + * randomness: the same inputs always produce the same split, so a budget + * change is explainable after the fact. + * + * Cents that rounding leaves over go to the best-performing campaign, so the + * shares sum to the total exactly. + */ +export function allocateDailyBudget(input: AllocationInput): ReadonlyMap { + const total = Math.max(0, input.totalUsd); + const campaigns = input.campaigns; + const out = new Map(); + + if (campaigns.length === 0 || total === 0) { + for (const campaign of campaigns) out.set(campaign.id, 0); + return out; + } + + const exploration = clamp(input.explorationShare ?? 0.2, 0, 1); + const evenPot = total * exploration; + const ratedPot = total - evenPot; + + const scores = campaigns.map((campaign) => ({ + id: campaign.id, + score: (Math.max(0, campaign.replies) + 1) / (Math.max(0, campaign.contacted) + 10), + })); + const scoreSum = scores.reduce((sum, entry) => sum + entry.score, 0); + + let allocated = 0; + for (const entry of scores) { + const share = roundUsd(evenPot / campaigns.length + (ratedPot * entry.score) / scoreSum); + out.set(entry.id, share); + allocated += share; + } + + // Rounding drift lands on the leader rather than being lost or overspent. + const drift = roundUsd(total - allocated); + if (drift !== 0) { + const leader = [...scores].sort((a, b) => b.score - a.score)[0]; + if (leader) out.set(leader.id, roundUsd((out.get(leader.id) ?? 0) + drift)); + } + + return out; +} + +/** + * Fits a set of campaign budgets under a project ceiling without reordering + * them. + * + * Used when autopilot is off: the customer set each campaign's budget by hand + * and the project ceiling is a cap, not a plan. Everything is scaled by the + * same factor, so the relative sizes they chose survive. Campaigns with no + * budget of their own are left alone. + */ +export function capUnderCeiling( + budgets: ReadonlyMap, + ceilingUsd: number, +): ReadonlyMap { + const sum = [...budgets.values()].reduce((acc, value) => acc + Math.max(0, value), 0); + if (sum <= ceilingUsd || sum === 0) return budgets; + + const factor = ceilingUsd / sum; + const out = new Map(); + for (const [id, value] of budgets) out.set(id, roundUsd(Math.max(0, value) * factor)); + return out; +} + +/** + * The lifecycle words the AutoGTM surface uses, mapped from what a campaign + * row actually holds. + * + * - `discovery`: finding leads, nothing sent yet (a draft, or nobody + * contacted). + * - `review`: sending is gated on a human — the approval queue. + * - `outreach`: sending unattended. + * - `listening`: paused; replies still land. + * - `archived`: finished. + */ +export type AutogtmStatus = 'discovery' | 'review' | 'outreach' | 'listening' | 'archived'; + +export function autogtmStatus(row: { + readonly status: string; + readonly approval_mode: string; + readonly contacted?: number; +}): AutogtmStatus { + if (row.status === 'archived') return 'archived'; + if (row.status === 'paused') return 'listening'; + if (row.status === 'draft') return 'discovery'; + if (row.approval_mode === 'trusted_automation') return 'outreach'; + if ((row.contacted ?? 0) === 0 && row.approval_mode === 'research_only') return 'discovery'; + return 'review'; +} + +/** Reply rate as a fraction, 0 when nothing has been sent. */ +export function replyRate(contacted: number, replies: number): number { + if (contacted <= 0) return 0; + return Math.round((replies / contacted) * 10_000) / 10_000; +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} diff --git a/packages/domain/src/ids.ts b/packages/domain/src/ids.ts index 80be2f3..6de91f7 100644 --- a/packages/domain/src/ids.ts +++ b/packages/domain/src/ids.ts @@ -58,6 +58,7 @@ export const ID_PREFIXES = { // keep it unguessable — a short or sequential token would let anyone // enumerate who a workspace has written to. trackedLink: 'tlk', + apiKey: 'key', unsubscribe: 'uns', linkClick: 'clk', cadence: 'cad', diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index da8c3a8..e6a9c3d 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -29,3 +29,4 @@ export * from './pipeline'; export * from './outreach'; export * from './video'; export * from './compliance'; +export * from './autogtm'; diff --git a/packages/pipeline/src/cadence-runner.ts b/packages/pipeline/src/cadence-runner.ts index 7a0a4ef..9b7bd94 100644 --- a/packages/pipeline/src/cadence-runner.ts +++ b/packages/pipeline/src/cadence-runner.ts @@ -15,6 +15,7 @@ import { newId, type CadenceStep, type Network } from '@outreachgraph/domain'; import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { matchKeysForPerson } from './suppression-keys'; import type { PolicyDecision, PolicyRequest } from '@outreachgraph/policy'; import { advanceCadences, type AdvanceResult, type DueEnrollment } from './cadence'; import { budgetStatus } from './metering'; @@ -279,18 +280,7 @@ async function conversationOpen( * the person being re-ingested by a later provider lookup. */ async function isSuppressed(db: Client, workspaceId: string, personId: string): Promise { - const identities = await queryAll<{ network: string; platform_user_id: string | null }>( - db, - 'SELECT network, platform_user_id FROM social_identities WHERE person_id = ?', - [personId], - ); - - const keys = [`person:${personId}`]; - for (const identity of identities) { - if (identity.platform_user_id) { - keys.push(`platform:${identity.network}:${identity.platform_user_id}`); - } - } + const keys = await matchKeysForPerson(db, personId); const placeholders = keys.map(() => '?').join(', '); const row = await queryOne<{ n: number }>( diff --git a/packages/pipeline/src/index.ts b/packages/pipeline/src/index.ts index 4277ee8..53bc8c1 100644 --- a/packages/pipeline/src/index.ts +++ b/packages/pipeline/src/index.ts @@ -298,3 +298,20 @@ export { type EnrichCandidateRow, type ProposeResult, } from './enrich'; + +export { + domainMatchKey, + emailMatchKey, + matchKeysForPerson, + normaliseDomain, + peopleMatchingKeys, +} from './suppression-keys'; +export { + applyProjectBudget, + applyProjectBudgets, + campaignBudgetFrom, + readBudgetJson, + setCampaignDailyBudget, + type ApplyResult, + type CampaignBudget, +} from './project-budgets'; diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 311a76d..3c719bb 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -18,6 +18,7 @@ import { type SignalType, } from '@outreachgraph/domain'; import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { matchKeysForPerson } from './suppression-keys'; import { resolveIdentity, type EvidenceInput } from '@outreachgraph/identity'; import { deriveEvidence, @@ -1162,18 +1163,7 @@ async function setStatus( } async function isSuppressed(db: Client, workspaceId: string, personId: string): Promise { - const identities = await queryAll<{ network: string; platform_user_id: string | null }>( - db, - 'SELECT network, platform_user_id FROM social_identities WHERE person_id = ?', - [personId], - ); - - const keys = [`person:${personId}`]; - for (const identity of identities) { - if (identity.platform_user_id) { - keys.push(`platform:${identity.network}:${identity.platform_user_id}`); - } - } + const keys = await matchKeysForPerson(db, personId); const placeholders = keys.map(() => '?').join(', '); const row = await queryOne<{ n: number }>( diff --git a/packages/pipeline/src/project-budgets.test.ts b/packages/pipeline/src/project-budgets.test.ts new file mode 100644 index 0000000..4884db9 --- /dev/null +++ b/packages/pipeline/src/project-budgets.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDatabase, migrate, now, queryOne, type Client } from '@outreachgraph/db'; +import { CONTACT_PRICE_USD } from '@outreachgraph/domain'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { + applyProjectBudget, + applyProjectBudgets, + campaignBudgetFrom, + setCampaignDailyBudget, +} from './project-budgets'; + +const MIGRATIONS_DIR = join(import.meta.dir, '../../../migrations'); + +let db: Client | undefined; +let path: string | undefined; + +afterEach(() => { + db?.close(); + if (path) for (const suffix of ['', '-wal', '-shm']) rmSync(`${path}${suffix}`, { force: true }); + db = undefined; + path = undefined; +}); + +async function fresh(label: string): Promise { + path = join(import.meta.dir, `../.test-budgets-${label}-${process.pid}.db`); + db = createDatabase({ url: `file:${path}` }); + await migrate(db, MIGRATIONS_DIR); + + const stamp = now(); + await db.batch([ + { + sql: `INSERT INTO organizations (id, name, slug, created_at, updated_at) + VALUES ('org_b', 'B', 'b', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO workspaces (id, organization_id, name, slug, created_at, updated_at) + VALUES ('wsp_b', 'org_b', 'B', 'b', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO offerings (id, workspace_id, name, category, created_at, updated_at) + VALUES ('off_b', 'wsp_b', 'Widget', 'saas', ?, ?)`, + args: [stamp, stamp], + }, + ...['cmp_1', 'cmp_2'].map((id) => ({ + sql: `INSERT INTO campaigns (id, workspace_id, name, offering_id, status, budget_json, + created_at, updated_at) + VALUES (?, 'wsp_b', ?, 'off_b', 'active', '{"minHoursBetweenActions": 4}', ?, ?)`, + args: [id, id, stamp, stamp], + })), + ]); + + return db; +} + +async function budgetOf(client: Client, campaignId: string) { + const row = await queryOne<{ budget_json: string }>( + client, + 'SELECT budget_json FROM campaigns WHERE id = ?', + [campaignId], + ); + return { raw: JSON.parse(row?.budget_json ?? '{}'), ...campaignBudgetFrom(row?.budget_json) }; +} + +describe('setCampaignDailyBudget', () => { + test('writes the dollars and the cap together, keeping other knobs', async () => { + const client = await fresh('set'); + + const stored = await setCampaignDailyBudget(client, 'wsp_b', 'cmp_1', 3); + expect(stored?.dailyBudgetUsd).toBe(3); + expect(stored?.maxActionsPerDay).toBe(Math.floor(3 / CONTACT_PRICE_USD)); + + const after = await budgetOf(client, 'cmp_1'); + expect(after.raw.minHoursBetweenActions).toBe(4); + expect(after.maxActionsPerDay).toBe(20); + }); + + test('null clears both', async () => { + const client = await fresh('clear'); + await setCampaignDailyBudget(client, 'wsp_b', 'cmp_1', 3); + const cleared = await setCampaignDailyBudget(client, 'wsp_b', 'cmp_1', null); + expect(cleared?.dailyBudgetUsd).toBeUndefined(); + expect(cleared?.maxActionsPerDay).toBeUndefined(); + expect((await budgetOf(client, 'cmp_1')).raw.minHoursBetweenActions).toBe(4); + }); + + test('another workspace cannot reach the campaign', async () => { + const client = await fresh('scope'); + expect(await setCampaignDailyBudget(client, 'wsp_other', 'cmp_1', 3)).toBeUndefined(); + }); +}); + +describe('applyProjectBudgets', () => { + test('a project without a ceiling is left alone', async () => { + const client = await fresh('noceiling'); + const result = await applyProjectBudgets(client, 'wsp_b'); + expect(result).toEqual({ changed: 0, projects: 0 }); + }); + + test('autopilot splits the ceiling across active campaigns', async () => { + const client = await fresh('split'); + await client.execute({ + sql: `UPDATE offerings SET daily_budget_usd = 6, autopilot = 1 WHERE id = 'off_b'`, + args: [], + }); + + const result = await applyProjectBudgets(client, 'wsp_b'); + expect(result.projects).toBe(1); + expect(result.changed).toBe(2); + + const one = await budgetOf(client, 'cmp_1'); + const two = await budgetOf(client, 'cmp_2'); + expect((one.dailyBudgetUsd ?? 0) + (two.dailyBudgetUsd ?? 0)).toBe(6); + expect(one.maxActionsPerDay).toBe(20); + + // Running again changes nothing: the allocator is idempotent. + expect((await applyProjectBudgets(client, 'wsp_b')).changed).toBe(0); + }); + + test('manual budgets are only scaled when they exceed the ceiling', async () => { + const client = await fresh('cap'); + await setCampaignDailyBudget(client, 'wsp_b', 'cmp_1', 30); + await setCampaignDailyBudget(client, 'wsp_b', 'cmp_2', 10); + await client.execute({ + sql: `UPDATE offerings SET daily_budget_usd = 20, autopilot = 0 WHERE id = 'off_b'`, + args: [], + }); + + expect(await applyProjectBudget(client, 'wsp_b', 'off_b')).toBe(2); + expect((await budgetOf(client, 'cmp_1')).dailyBudgetUsd).toBe(15); + expect((await budgetOf(client, 'cmp_2')).dailyBudgetUsd).toBe(5); + + // Raise the ceiling: nothing is scaled back up, the customer's numbers stand. + await client.execute({ + sql: `UPDATE offerings SET daily_budget_usd = 100 WHERE id = 'off_b'`, + args: [], + }); + expect(await applyProjectBudget(client, 'wsp_b', 'off_b')).toBe(0); + }); + + test('a paused campaign is not allocated to', async () => { + const client = await fresh('paused'); + await client.execute({ + sql: `UPDATE campaigns SET status = 'paused' WHERE id = 'cmp_2'`, + args: [], + }); + await client.execute({ + sql: `UPDATE offerings SET daily_budget_usd = 6, autopilot = 1 WHERE id = 'off_b'`, + args: [], + }); + + await applyProjectBudgets(client, 'wsp_b'); + expect((await budgetOf(client, 'cmp_1')).dailyBudgetUsd).toBe(6); + expect((await budgetOf(client, 'cmp_2')).dailyBudgetUsd).toBeUndefined(); + }); +}); diff --git a/packages/pipeline/src/project-budgets.ts b/packages/pipeline/src/project-budgets.ts new file mode 100644 index 0000000..19d11ef --- /dev/null +++ b/packages/pipeline/src/project-budgets.ts @@ -0,0 +1,221 @@ +/** + * Dollars per day, per project, turned into caps the policy engine enforces. + * + * Nothing here sends anything and nothing here refuses anything. A budget set + * through the AutoGTM surface becomes `dailyBudgetUsd` and `maxActionsPerDay` + * on the campaign's `budget_json`, and from there the existing engine reads it + * the same way it reads a cap set from the settings page. That is the whole + * design: a limit enforced outside the policy engine is a limit the approval + * queue, autopilot and the cadence runner each have to remember separately. + * + * The allocator runs once a tick per workspace and once, synchronously, after + * any change to a project's budget or autopilot, so the number the API hands + * back is the number the worker will act on. + */ + +import { + allocateDailyBudget, + capUnderCeiling, + dailyContactsFor, + roundUsd, +} from '@outreachgraph/domain'; +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; + +/** How far back reply performance is read when splitting a budget. */ +const PERFORMANCE_WINDOW_DAYS = 14; + +export interface CampaignBudget { + readonly dailyBudgetUsd: number | undefined; + readonly maxActionsPerDay: number | undefined; +} + +export function readBudgetJson(text: string | null | undefined): Record { + if (!text) return {}; + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +export function campaignBudgetFrom(text: string | null | undefined): CampaignBudget { + const budget = readBudgetJson(text); + return { + dailyBudgetUsd: + typeof budget.dailyBudgetUsd === 'number' && Number.isFinite(budget.dailyBudgetUsd) + ? budget.dailyBudgetUsd + : undefined, + maxActionsPerDay: + typeof budget.maxActionsPerDay === 'number' && Number.isFinite(budget.maxActionsPerDay) + ? budget.maxActionsPerDay + : undefined, + }; +} + +/** + * Sets one campaign's daily budget, writing the cap alongside it. + * + * `null` clears both, returning the campaign to whatever cap it had by + * default. Merged into the existing budget so the anti-spam knobs beside it + * survive. Returns the stored budget, or undefined for a campaign this + * workspace does not hold. + */ +export async function setCampaignDailyBudget( + db: Client, + workspaceId: string, + campaignId: string, + dailyBudgetUsd: number | null, +): Promise { + const row = await queryOne<{ budget_json: string }>( + db, + 'SELECT budget_json FROM campaigns WHERE id = ? AND workspace_id = ?', + [campaignId, workspaceId], + ); + if (!row) return undefined; + + const current = readBudgetJson(row.budget_json); + const next: Record = { ...current }; + + if (dailyBudgetUsd === null) { + delete next.dailyBudgetUsd; + delete next.maxActionsPerDay; + } else { + next.dailyBudgetUsd = roundUsd(dailyBudgetUsd); + next.maxActionsPerDay = dailyContactsFor(dailyBudgetUsd); + } + + const serialised = JSON.stringify(next); + if (serialised !== JSON.stringify(current)) { + await db.execute({ + sql: 'UPDATE campaigns SET budget_json = ?, updated_at = ? WHERE id = ? AND workspace_id = ?', + args: [serialised, now(), campaignId, workspaceId], + }); + } + + return campaignBudgetFrom(serialised); +} + +export interface ProjectBudgetRow { + readonly id: string; + readonly daily_budget_usd: number | null; + readonly autopilot: number; +} + +export interface ApplyResult { + /** Campaigns whose stored budget changed. */ + readonly changed: number; + readonly projects: number; +} + +/** + * Brings every campaign's budget into line with its project's. + * + * Autopilot on: the project's ceiling is split across its active campaigns by + * reply rate and written to each. Autopilot off: campaign budgets are the + * customer's own and are only scaled down when together they would exceed the + * ceiling. A project without a ceiling is left entirely alone, which is every + * project that predates this. + */ +export async function applyProjectBudgets(db: Client, workspaceId: string): Promise { + const projects = await queryAll( + db, + `SELECT id, daily_budget_usd, autopilot FROM offerings + WHERE workspace_id = ? AND daily_budget_usd IS NOT NULL`, + [workspaceId], + ); + + let changed = 0; + + for (const project of projects) { + changed += await applyOneProject(db, workspaceId, project); + } + + return { changed, projects: projects.length }; +} + +/** The same reconciliation for one project, run after its settings change. */ +export async function applyProjectBudget( + db: Client, + workspaceId: string, + offeringId: string, +): Promise { + const project = await queryOne( + db, + `SELECT id, daily_budget_usd, autopilot FROM offerings WHERE id = ? AND workspace_id = ?`, + [offeringId, workspaceId], + ); + if (!project || project.daily_budget_usd === null) return 0; + return applyOneProject(db, workspaceId, project); +} + +async function applyOneProject( + db: Client, + workspaceId: string, + project: ProjectBudgetRow, +): Promise { + const ceiling = Math.max(0, project.daily_budget_usd ?? 0); + const since = new Date(Date.now() - PERFORMANCE_WINDOW_DAYS * 24 * 3_600_000).toISOString(); + + const campaigns = await queryAll<{ + id: string; + budget_json: string; + contacted: number; + replies: number; + }>( + db, + `SELECT c.id, c.budget_json, + (SELECT COUNT(*) FROM interactions i + JOIN campaign_people cp ON cp.person_id = i.person_id AND cp.campaign_id = c.id + WHERE i.workspace_id = c.workspace_id AND i.direction = 'outbound' + AND i.occurred_at >= ?) AS contacted, + (SELECT COUNT(*) FROM interactions i + JOIN campaign_people cp ON cp.person_id = i.person_id AND cp.campaign_id = c.id + WHERE i.workspace_id = c.workspace_id AND i.direction = 'inbound' + AND i.occurred_at >= ?) AS replies + FROM campaigns c + WHERE c.workspace_id = ? AND c.offering_id = ? AND c.status NOT IN ('paused', 'archived', 'draft') + ORDER BY c.created_at`, + [since, since, workspaceId, project.id], + ); + + if (campaigns.length === 0) return 0; + + let target: ReadonlyMap; + + if (project.autopilot === 1) { + target = allocateDailyBudget({ + totalUsd: ceiling, + campaigns: campaigns.map((row) => ({ + id: row.id, + contacted: Number(row.contacted), + replies: Number(row.replies), + })), + }); + } else { + const own = new Map(); + for (const row of campaigns) { + const budget = campaignBudgetFrom(row.budget_json); + if (budget.dailyBudgetUsd !== undefined) own.set(row.id, budget.dailyBudgetUsd); + } + target = capUnderCeiling(own, ceiling); + } + + let changed = 0; + for (const row of campaigns) { + const wanted = target.get(row.id); + if (wanted === undefined) continue; + + const before = campaignBudgetFrom(row.budget_json); + if (before.dailyBudgetUsd === wanted && before.maxActionsPerDay === dailyContactsFor(wanted)) { + continue; + } + + await setCampaignDailyBudget(db, workspaceId, row.id, wanted); + changed += 1; + } + + return changed; +} diff --git a/packages/pipeline/src/suppression-keys.test.ts b/packages/pipeline/src/suppression-keys.test.ts new file mode 100644 index 0000000..e7fd8e4 --- /dev/null +++ b/packages/pipeline/src/suppression-keys.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test'; +import { domainMatchKey, emailMatchKey, normaliseDomain } from './suppression-keys'; + +describe('match keys', () => { + test('email keys are case and whitespace insensitive', () => { + expect(emailMatchKey(' Jane@Example.COM ')).toBe('email:jane@example.com'); + }); + + test('domain keys strip scheme, www, path and port', () => { + expect(normaliseDomain('https://www.Example.com/about?x=1')).toBe('example.com'); + expect(normaliseDomain('example.com:8443')).toBe('example.com'); + expect(domainMatchKey('WWW.Acme.io')).toBe('domain:acme.io'); + }); +}); diff --git a/packages/pipeline/src/suppression-keys.ts b/packages/pipeline/src/suppression-keys.ts new file mode 100644 index 0000000..2d2dfb4 --- /dev/null +++ b/packages/pipeline/src/suppression-keys.ts @@ -0,0 +1,126 @@ +/** + * Every spelling under which a person can be suppressed. + * + * Suppression started as `person:` plus `platform::`, which + * is exact and survives deletion but cannot express "never write to anyone at + * this company" or "this address, whoever it turns out to belong to". Named + * suppress lists add `email:
` and `domain:`, and this is the + * one function that knows all four — the pipeline, the cadence runner and the + * API each used to build the list themselves, and a fifth spelling added in + * one of them would have been a hole in the other two. + */ + +import { queryAll, queryOne, type Client } from '@outreachgraph/db'; + +export function emailMatchKey(address: string): string { + return `email:${address.trim().toLowerCase()}`; +} + +export function domainMatchKey(domain: string): string { + return `domain:${normaliseDomain(domain)}`; +} + +/** `www.Example.com/` and `example.com` are one company. */ +export function normaliseDomain(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^www\./, '') + .replace(/[/?#].*$/, '') + .replace(/:\d+$/, ''); +} + +export async function matchKeysForPerson(db: Client, personId: string): Promise { + const keys = new Set([`person:${personId}`]); + + const identities = await queryAll<{ + network: string; + handle: string | null; + platform_user_id: string | null; + }>(db, 'SELECT network, handle, platform_user_id FROM social_identities WHERE person_id = ?', [ + personId, + ]); + + for (const identity of identities) { + if (identity.platform_user_id) { + keys.add(`platform:${identity.network}:${identity.platform_user_id}`); + } + if (identity.network === 'email' && identity.handle?.trim()) { + keys.add(emailMatchKey(identity.handle)); + } + } + + const imported = await queryAll<{ address: string }>( + db, + 'SELECT address FROM person_emails WHERE person_id = ?', + [personId], + ); + for (const row of imported) { + if (row.address?.trim()) keys.add(emailMatchKey(row.address)); + } + + const company = await queryOne<{ domain: string | null; contact_email: string | null }>( + db, + `SELECT co.domain, co.contact_email + FROM people p JOIN companies co ON co.id = p.current_company_id + WHERE p.id = ?`, + [personId], + ); + if (company?.domain?.trim()) keys.add(domainMatchKey(company.domain)); + // A shared inbox is still an address someone asked us not to write to. + if (company?.contact_email?.trim()) keys.add(emailMatchKey(company.contact_email)); + + return [...keys]; +} + +/** + * People this workspace holds who match an email or domain key right now. + * + * Used when a list is created, so what is already queued for them stops + * today rather than at the next policy check — and so `person:` keys can be + * written alongside, which is what keeps them suppressed if the address or + * company on their record later changes. + */ +export async function peopleMatchingKeys( + db: Client, + workspaceId: string, + keys: readonly string[], +): Promise { + const emails = keys.filter((key) => key.startsWith('email:')).map((key) => key.slice(6)); + const domains = keys.filter((key) => key.startsWith('domain:')).map((key) => key.slice(7)); + const found = new Set(); + + if (emails.length > 0) { + const placeholders = emails.map(() => '?').join(', '); + const rows = await queryAll<{ id: string }>( + db, + `SELECT DISTINCT p.id + FROM people p + JOIN campaign_people cp ON cp.person_id = p.id AND cp.workspace_id = ? + WHERE EXISTS (SELECT 1 FROM social_identities si + WHERE si.person_id = p.id AND si.network = 'email' + AND lower(trim(si.handle)) IN (${placeholders})) + OR EXISTS (SELECT 1 FROM person_emails pe + WHERE pe.person_id = p.id AND lower(trim(pe.address)) IN (${placeholders}))`, + [workspaceId, ...emails, ...emails], + ); + for (const row of rows) found.add(row.id); + } + + if (domains.length > 0) { + const placeholders = domains.map(() => '?').join(', '); + const rows = await queryAll<{ id: string }>( + db, + `SELECT DISTINCT p.id + FROM people p + JOIN companies co ON co.id = p.current_company_id + JOIN campaign_people cp ON cp.person_id = p.id AND cp.workspace_id = ? + WHERE lower(trim(co.domain)) IN (${placeholders})`, + [workspaceId, ...domains], + ); + for (const row of rows) found.add(row.id); + } + + return [...found]; +} diff --git a/packages/policy/src/engine.test.ts b/packages/policy/src/engine.test.ts index 27aa2a5..7de63eb 100644 --- a/packages/policy/src/engine.test.ts +++ b/packages/policy/src/engine.test.ts @@ -368,6 +368,52 @@ describe('a contact who has replied', () => { expect(isExecutable(result.decision, false)).toBe(false); }); + test('answering an open thread is not paced like cold outreach', () => { + // The weekly per-prospect cap and the cooldown exist to stop pestering + // someone who has not answered. A reply to someone who wrote to us is the + // opposite case, and used to be refused by the very gate meant to protect + // them — one email out, one reply in, and the answer was "weekly limit + // reached (1/1)". + const result = evaluatePolicy( + request({ + conversationOpen: true, + isFollowUp: true, + actionsToThisProspectThisWeek: 1, + maxActionsPerProspectPerWeek: 1, + hoursSinceLastActionToProspect: 2, + minHoursBetweenActions: 48, + }), + ); + + expect(result.decision).toBe('allow_with_approval'); + expect(result.gate).toBe('conversation_open'); + }); + + test('the same pacing still applies to cold outreach', () => { + const result = evaluatePolicy( + request({ + actionsToThisProspectThisWeek: 1, + maxActionsPerProspectPerWeek: 1, + }), + ); + expect(result.decision).toBe('deny'); + expect(result.gate).toBe('rate_limit_prospect'); + }); + + test('the daily cap and budget still bind a follow-up', () => { + const daily = evaluatePolicy( + request({ conversationOpen: true, isFollowUp: true, actionsToday: 50, maxActionsPerDay: 50 }), + ); + expect(daily.decision).toBe('deny'); + expect(daily.gate).toBe('rate_limit_daily'); + + const budget = evaluatePolicy( + request({ conversationOpen: true, isFollowUp: true, budgetExhausted: true }), + ); + expect(budget.decision).toBe('deny'); + expect(budget.gate).toBe('budget_exhausted'); + }); + test('a follow-up is still refused when a rate limit already denied it', () => { // `deny` outranks `allow_with_approval`, so the follow-up downgrade must // not be able to loosen a decision another gate has already tightened. diff --git a/packages/policy/src/engine.ts b/packages/policy/src/engine.ts index ec9aeed..2d4fd82 100644 --- a/packages/policy/src/engine.ts +++ b/packages/policy/src/engine.ts @@ -326,7 +326,17 @@ export function evaluatePolicy(request: PolicyRequest): PolicyResult { `Daily action limit reached (${request.actionsToday}/${request.maxActionsPerDay}).`, ); } - if (request.actionsToThisProspectThisWeek >= request.maxActionsPerProspectPerWeek) { + // The per-prospect pacing gates exist to stop pestering someone who has + // not answered. Answering someone who wrote to us is the opposite of + // that, so a flagged follow-up on an open conversation skips them — and + // only them: the daily cap, the budget and every suppression gate still + // apply, and 7b below still routes it to a human. + const answering = request.isFollowUp === true && request.conversationOpen === true; + + if ( + !answering && + request.actionsToThisProspectThisWeek >= request.maxActionsPerProspectPerWeek + ) { restrict( 'rate_limit_prospect', 'deny', @@ -337,7 +347,7 @@ export function evaluatePolicy(request: PolicyRequest): PolicyResult { const cooldown = request.minHoursBetweenActions ?? DEFAULT_COOLDOWN_HOURS; const elapsed = request.hoursSinceLastActionToProspect; - if (elapsed !== undefined && elapsed < cooldown) { + if (!answering && elapsed !== undefined && elapsed < cooldown) { restrict( 'cooldown', 'deny',