Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,15 @@ describe('adding a prospect (PRD §8)', () => {
/** Collects sent messages instead of delivering them. */
function recordingMailer(): { sent: Message[]; mailer: Mailer } {
const sent: Message[] = [];
return { sent, mailer: { send: async (message) => void sent.push(message) } };
return {
sent,
mailer: {
send: async (message) => {
sent.push(message);
return {};
},
},
};
}

describe('email verification', () => {
Expand Down
191 changes: 191 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ import {
workspacesForUser,
} from './auth';
import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy';
import {
campaignFunnel,
leadTimeline,
loadNotifySettings,
notifyAddress,
workspaceAnalytics,
} from '@outreachgraph/pipeline';
import {
createCampaignFromIntake,
IntakeError,
saveWorkspaceSettings,
setCampaignAutopilot,
} from './campaigns';
import { draftForRecommendation, draftProfile, type TextModel } from '@outreachgraph/ai';
import { batchStatus, enqueue, runPipeline } from '@outreachgraph/pipeline';
import { GitHubProvider, SiteProvider, normaliseUrl } from '@outreachgraph/providers';
Expand Down Expand Up @@ -324,6 +337,88 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
return c.json({ campaign });
});

/**
* The front door (PRD §8).
*
* One box, two paths. A company website is crawled directly; a description
* of a market goes to discovery, which names real companies and queues a
* crawl for each. The caller gets a campaign id straight back and watches it
* fill, because neither path can finish inside a request.
*/
api.post('/campaigns', async (c) => {
const actor = c.get('actor');
const db = c.get('db');

if (!canApprove(actor)) throw ApiError.forbidden('starting a campaign');
await requireVerifiedEmail(db, actor);

const body = safeJson(await c.req.raw.text());
const input = typeof body.input === 'string' ? body.input : '';

let result;
try {
result = await createCampaignFromIntake(db, actor, input, {
autopilot: body.autopilot === true,
...(typeof body.name === 'string' ? { name: body.name } : {}),
});
} catch (error) {
if (error instanceof IntakeError) {
throw ApiError.badRequest(error.message, { input: [error.message] });
}
throw error;
}

await repo.audit(db, {
workspaceId: actor.workspaceId,
actorKind: 'user',
actorId: actor.userId,
eventType: 'campaign.created',
entityKind: 'campaign',
entityId: result.campaignId,
detail: { kind: result.kind, seed: result.seed, autopilot: result.autopilot },
});

return c.json(result, 202);
});

/** Switches autopilot on or off. The only control unattended sending has. */
api.patch('/campaigns/:id', async (c) => {
const actor = c.get('actor');
const db = c.get('db');

if (!canApprove(actor)) throw ApiError.forbidden('changing a campaign');

const body = safeJson(await c.req.raw.text());
if (typeof body.autopilot !== 'boolean') {
throw ApiError.badRequest('autopilot must be true or false');
}

// Turning it *on* spends money without asking again, so it carries the
// same verification gate as starting a campaign.
if (body.autopilot) await requireVerifiedEmail(db, actor);

const updated = await setCampaignAutopilot(
db,
actor.workspaceId,
c.req.param('id'),
body.autopilot,
);

if (!updated) throw ApiError.notFound('campaign');

await repo.audit(db, {
workspaceId: actor.workspaceId,
actorKind: 'user',
actorId: actor.userId,
eventType: body.autopilot ? 'campaign.autopilot_enabled' : 'campaign.autopilot_disabled',
entityKind: 'campaign',
entityId: c.req.param('id'),
detail: {},
});

return c.json({ campaignId: c.req.param('id'), autopilot: body.autopilot });
});

api.get('/campaigns/:id/recommendations', async (c) => {
const actor = c.get('actor');
const limit = clampLimit(c.req.query('limit'));
Expand Down Expand Up @@ -1066,6 +1161,102 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
return c.json({ executed: true, actionId: action.id });
});

// ------------------------------------------------------------- analytics
/**
* The funnel, plus the headline numbers above it (PRD §25).
*
* Stage counts come from the event log rather than the current-status
* column, which is the only way "how many ever reached Contacted" is
* answerable at all — see `lead_stage_events`.
*/
api.get('/analytics', async (c) => {
const actor = c.get('actor');
const campaignId = c.req.query('campaignId');

const [analytics, funnel] = await Promise.all([
workspaceAnalytics(c.get('db'), actor.workspaceId),
campaignId
? campaignFunnel(c.get('db'), { workspaceId: actor.workspaceId, campaignId })
: undefined,
]);

// A campaign-scoped request still gets the workspace headline numbers, so
// the page has one request rather than two.
return c.json({ ...analytics, ...(funnel ? { funnel } : {}) });
});

/** One row per lead, each a sequence of dated stage segments. */
api.get('/analytics/timeline', async (c) => {
const actor = c.get('actor');
const campaignId = c.req.query('campaignId');

const leads = await leadTimeline(c.get('db'), {
workspaceId: actor.workspaceId,
...(campaignId ? { campaignId } : {}),
limit: clampLimit(c.req.query('limit')),
});

return c.json({ leads });
});

// -------------------------------------------------------------- settings
api.get('/settings', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
const settings = await loadNotifySettings(db, actor.workspaceId);
const address = await notifyAddress(db, actor.workspaceId, settings);

const cap = await queryOne<{ autopilot_daily_cap: number; reply_to_email: string | null }>(
db,
`SELECT autopilot_daily_cap, reply_to_email FROM workspace_settings WHERE workspace_id = ?`,
[actor.workspaceId],
);

return c.json({
notifyEmail: settings.notify_email,
// What mail would actually go to right now, resolved through the owner
// fallback. Without this the settings page can only show an empty box
// and leave the user unsure whether anything is configured at all.
effectiveNotifyEmail: address ?? null,
instantAlerts: settings.instant_alerts === 1,
dailyDigest: settings.daily_digest === 1,
digestHourUtc: settings.digest_hour_utc,
alertMinOpportunity: settings.alert_min_opportunity,
autopilotDailyCap: cap?.autopilot_daily_cap ?? 25,
replyToEmail: cap?.reply_to_email ?? null,
lastDigestSentOn: settings.last_digest_sent_on,
});
});

api.put('/settings', async (c) => {
const actor = c.get('actor');
const db = c.get('db');

if (!canApprove(actor)) throw ApiError.forbidden('changing workspace settings');

const body = safeJson(await c.req.raw.text());

await saveWorkspaceSettings(db, actor.workspaceId, {
...(body.notifyEmail === undefined
? {}
: { notifyEmail: body.notifyEmail === null ? null : String(body.notifyEmail) }),
...(body.replyToEmail === undefined
? {}
: { replyToEmail: body.replyToEmail === null ? null : String(body.replyToEmail) }),
instantAlerts: body.instantAlerts !== false,
dailyDigest: body.dailyDigest !== false,
...(body.digestHourUtc === undefined ? {} : { digestHourUtc: Number(body.digestHourUtc) }),
...(body.alertMinOpportunity === undefined
? {}
: { alertMinOpportunity: Number(body.alertMinOpportunity) }),
...(body.autopilotDailyCap === undefined
? {}
: { autopilotDailyCap: Number(body.autopilotDailyCap) }),
});

return c.json({ saved: true });
});

// --------------------------------------------------------------- signals
api.get('/signals', async (c) => {
const actor = c.get('actor');
Expand Down
163 changes: 163 additions & 0 deletions apps/api/src/campaigns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { queryAll, queryOne } from '@outreachgraph/db';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
import { createCampaignFromIntake, IntakeError, setCampaignAutopilot } from './campaigns';

let seeded: SeededDatabase | undefined;

afterEach(() => {
seeded?.cleanup();
seeded = undefined;
});

const ACTOR = { workspaceId: SEED.workspaceId, userId: SEED.userId };

describe('createCampaignFromIntake', () => {
test('a website is crawled directly', async () => {
seeded = await seedDatabase('intake-url');
const { db } = seeded;

const result = await createCampaignFromIntake(db, ACTOR, 'https://www.brightsmile.com/about');

expect(result.kind).toBe('url');
expect(result.seed).toBe('brightsmile.com');
expect(result.queued).toBe(1);

const jobs = await queryAll<{ kind: string; payload_json: string }>(
db,
`SELECT kind, payload_json FROM jobs WHERE workspace_id = ?`,
[SEED.workspaceId],
);
expect(jobs).toHaveLength(1);
expect(jobs[0]?.kind).toBe('crawl_site');

// The crawl must know which campaign it belongs to, or it files its people
// under whichever campaign happens to be oldest.
const payload = JSON.parse(jobs[0]?.payload_json ?? '{}');
expect(payload.campaignId).toBe(result.campaignId);
expect(payload.url).toBe('https://brightsmile.com');
});

test('a description queues discovery instead', async () => {
seeded = await seedDatabase('intake-keyword');
const { db } = seeded;

const result = await createCampaignFromIntake(db, ACTOR, 'dental practices in Austin');

expect(result.kind).toBe('keyword');
expect(result.queued).toBe(0);

const job = await queryOne<{ kind: string; payload_json: string }>(
db,
`SELECT kind, payload_json FROM jobs WHERE workspace_id = ?`,
[SEED.workspaceId],
);
expect(job?.kind).toBe('discover_domains');
expect(JSON.parse(job?.payload_json ?? '{}').keyword).toBe('dental practices in Austin');
});

test('autopilot is opt-in and sets the approval mode the policy engine reads', async () => {
seeded = await seedDatabase('intake-autopilot');
const { db } = seeded;

const off = await createCampaignFromIntake(db, ACTOR, 'acme.com');
const on = await createCampaignFromIntake(db, ACTOR, 'other.com', { autopilot: true });

const modes = await queryAll<{ id: string; approval_mode: string }>(
db,
`SELECT id, approval_mode FROM campaigns WHERE id IN (?, ?)`,
[off.campaignId, on.campaignId],
);

const byId = new Map(modes.map((row) => [row.id, row.approval_mode]));
expect(byId.get(off.campaignId)).toBe('draft_and_approve');
expect(byId.get(on.campaignId)).toBe('trusted_automation');
});

test('a workspace with no offering still gets a campaign, and is told', async () => {
seeded = await seedDatabase('intake-no-offering');
const { db } = seeded;

// `campaigns.offering_id` is NOT NULL, so without a placeholder the very
// first thing a new account does would fail on a foreign key.
await db.execute({ sql: `DELETE FROM campaigns`, args: [] });
await db.execute({ sql: `DELETE FROM offerings`, args: [] });

const result = await createCampaignFromIntake(db, ACTOR, 'acme.com');

expect(result.needsProfile).toBe(true);
expect(result.campaignId).toBeTruthy();
});

test('an existing offering is reused rather than duplicated', async () => {
seeded = await seedDatabase('intake-reuse-offering');
const { db } = seeded;

await createCampaignFromIntake(db, ACTOR, 'acme.com');

const offerings = await queryAll<{ id: string }>(
db,
`SELECT id FROM offerings WHERE workspace_id = ?`,
[SEED.workspaceId],
);
expect(offerings).toHaveLength(1);
});

test('unusable input is rejected with something worth reading', async () => {
seeded = await seedDatabase('intake-empty');
const { db } = seeded;

await expect(createCampaignFromIntake(db, ACTOR, ' ')).rejects.toBeInstanceOf(IntakeError);
});

test('the seed is recorded so a campaign traces back to what was typed', async () => {
seeded = await seedDatabase('intake-seed');
const { db } = seeded;

const result = await createCampaignFromIntake(db, ACTOR, 'bike shops in Portland');

const campaign = await queryOne<{ seed_kind: string; seed_value: string }>(
db,
`SELECT seed_kind, seed_value FROM campaigns WHERE id = ?`,
[result.campaignId],
);
expect(campaign?.seed_kind).toBe('keyword');
expect(campaign?.seed_value).toBe('bike shops in Portland');
});
});

describe('setCampaignAutopilot', () => {
test('switches the mode both ways', async () => {
seeded = await seedDatabase('autopilot-toggle');
const { db } = seeded;

expect(await setCampaignAutopilot(db, SEED.workspaceId, SEED.campaignId, true)).toBe(true);
expect(
(
await queryOne<{ approval_mode: string }>(
db,
`SELECT approval_mode FROM campaigns WHERE id = ?`,
[SEED.campaignId],
)
)?.approval_mode,
).toBe('trusted_automation');

await setCampaignAutopilot(db, SEED.workspaceId, SEED.campaignId, false);
expect(
(
await queryOne<{ approval_mode: string }>(
db,
`SELECT approval_mode FROM campaigns WHERE id = ?`,
[SEED.campaignId],
)
)?.approval_mode,
).toBe('draft_and_approve');
});

test('a campaign in another workspace is not reachable', async () => {
seeded = await seedDatabase('autopilot-scope');
const { db } = seeded;

expect(await setCampaignAutopilot(db, 'wsp_other', SEED.campaignId, true)).toBe(false);
});
});
Loading
Loading