From 7aea06d5f4dfa02f92a48563c5629265d8448125 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 13 Aug 2026 04:35:23 +0000 Subject: [PATCH] feat: read your own site and draft your profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup step: paste your URL, a model reads the page, and you get back a draft of your business — what you sell, who plausibly buys it, how you write, and where those buyers are active. You correct it, then save. The mirror image of the crawler that reads a prospect's site. Same fetch and the same text extraction; the difference is whose page it is and what comes out. There the page yields leads, here it yields the workspace's own offering, ICP and voice. Three tables that already existed and nothing ever filled — `offerings`, `campaign_filters` and `voice_profiles`. Until now the only writer was the test seed, and the campaign bootstrap created an offering literally named "Your offering" whose description read "Describe what you sell here" — which is the text every draft was then grounded in. Verified email is required before the model runs, and the test asserts the gate sits in front of the spend rather than in front of the reply: an unverified signup that still burned a call would be an invitation to do it a thousand times. Nothing is written by the draft call. What comes back is a model's reading of a marketing page — a starting point, not a fact about someone's business — so it is rendered as editable fields and `PUT` is what commits it. Saving twice corrects the profile rather than creating a rival one, because this is a step people re-run after changing their site. "Configured" keys off the voice profile, since only completing setup creates one. The first version compared the offering's name to the literal string "Your offering", which is the kind of check that stops working the day somebody edits the bootstrap copy. The contact scraper stays. It is a different job — mining other people's sites for leads — and worth keeping. 11 new tests, 480 total. Known limit, stated rather than hidden: this needs the Anthropic key, which is capped until 2026-09-01. Until then the route answers 502 with the model's own reason, and the page says to fill it in by hand. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/app.ts | 135 +++++++++- apps/api/src/onboarding.test.ts | 274 ++++++++++++++++++++ apps/api/src/workspace-profile.ts | 267 ++++++++++++++++++++ apps/web/app/(app)/setup/page.tsx | 65 +++++ apps/web/app/(app)/today/page.tsx | 24 +- apps/web/components/profile-setup.tsx | 321 ++++++++++++++++++++++++ apps/web/lib/api.ts | 11 + packages/ai/src/index.ts | 2 + packages/ai/src/profile.ts | 178 +++++++++++++ packages/contracts/src/index.ts | 37 +++ packages/providers/src/site/provider.ts | 11 +- 11 files changed, 1319 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/onboarding.test.ts create mode 100644 apps/api/src/workspace-profile.ts create mode 100644 apps/web/app/(app)/setup/page.tsx create mode 100644 apps/web/components/profile-setup.tsx create mode 100644 packages/ai/src/profile.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 23f34ca..15dd76a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,6 +19,7 @@ import { privacyRequestSchema, registerSchema, snoozeRecommendationSchema, + workspaceProfileSchema, } from '@outreachgraph/contracts'; import { newId, type ActionKind, type Network } from '@outreachgraph/domain'; import { now, queryOne, type Client } from '@outreachgraph/db'; @@ -37,12 +38,13 @@ import { workspacesForUser, } from './auth'; import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy'; -import { draftForRecommendation, type TextModel } from '@outreachgraph/ai'; +import { draftForRecommendation, draftProfile, type TextModel } from '@outreachgraph/ai'; import { batchStatus, enqueue, runPipeline } from '@outreachgraph/pipeline'; -import { GitHubProvider, normaliseUrl } from '@outreachgraph/providers'; +import { GitHubProvider, SiteProvider, normaliseUrl } from '@outreachgraph/providers'; import { ConsoleMailer, verificationEmail, type Mailer } from '@outreachgraph/email'; import { ApiError, canApprove, type AppEnv, type RequestActor } from './context'; import * as repo from './repository'; +import { loadWorkspaceProfile, saveWorkspaceProfile } from './workspace-profile'; /** One paste, one reviewable unit of work. */ const MAX_BULK_URLS = 100; @@ -71,6 +73,8 @@ export interface AppOptions { * inject a fake; production leaves it unset and gets the real client. */ readonly github?: GitHubProvider; + /** Reads the customer's own site during onboarding. Tests inject a fake. */ + readonly site?: SiteProvider; /** * Sends account email. Omit to log messages instead of sending them, which * is what local development and the test suite do. @@ -396,6 +400,125 @@ export function createApp(options: AppOptions): Hono { }); }); + // ---------------------------------------------------------- onboarding + /** + * Reads your own site and drafts your profile (PRD §7, §11). + * + * The mirror image of the crawler that reads a prospect's site: the page + * belongs to the person signing up, and what comes back describes their + * business rather than a lead — what they sell, who plausibly buys it, how + * they write, and where those buyers are active. + * + * Nothing is saved here. The reply is a draft to correct, because it is a + * model's reading of a marketing page and not a fact about someone's company. + * `PUT` is what commits it. + * + * Verified email is required before the model runs. An unverified signup is + * an unattributed one, and this is the first route where a stranger can spend + * our tokens. + */ + api.post('/onboarding/profile', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + + if (!canApprove(actor)) throw ApiError.forbidden('setting up the workspace'); + await requireVerifiedEmail(db, actor, 'we read your site'); + + if (!options.model) { + throw new ApiError( + 503, + 'composer_unavailable', + 'no language model is configured; fill the profile in by hand for now', + ); + } + + const raw = safeJson(await c.req.raw.text()); + if (typeof raw.url !== 'string' || !raw.url.trim()) { + throw ApiError.badRequest('enter your website address', { url: ['required'] }); + } + + let target: URL; + try { + target = new URL(normaliseUrl(raw.url)); + } catch { + throw ApiError.badRequest('that does not look like a web address', { url: ['invalid'] }); + } + + const page = await (options.site ?? new SiteProvider()).crawl(target.toString()); + if (page.outcome !== 'ok') { + // The site's own answer, phrased for the person who owns it. + const why = + page.outcome === 'robots_denied' + ? 'your robots.txt blocks us from reading it' + : `we could not read it (${page.outcome})`; + throw ApiError.badRequest(`we could not read ${target.hostname}: ${why}`, { + url: [page.outcome], + }); + } + + const result = await draftProfile(options.model, page.pageText ?? '', page.finalUrl); + if (!result.ok || !result.draft) { + throw new ApiError(502, 'profile_draft_failed', result.reason ?? 'could not draft a profile'); + } + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'onboarding.profile_drafted', + entityKind: 'workspace', + entityId: actor.workspaceId, + detail: { url: page.finalUrl }, + }); + + return c.json({ + url: page.finalUrl, + companyName: page.company.name, + draft: result.draft, + }); + }); + + /** + * Commits the profile the person confirmed. + * + * Takes the edited draft rather than re-reading the site: what is saved has + * to be what they approved, and re-running the model here would quietly + * replace their corrections with a fresh guess. + */ + api.put('/onboarding/profile', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + + if (!canApprove(actor)) throw ApiError.forbidden('setting up the workspace'); + + const raw = safeJson(await c.req.raw.text()); + const parsed = workspaceProfileSchema.safeParse(raw); + if (!parsed.success) { + throw ApiError.badRequest('that profile is incomplete', parsed.error.flatten().fieldErrors); + } + + const saved = await saveWorkspaceProfile(db, actor.workspaceId, parsed.data); + + await repo.audit(db, { + workspaceId: actor.workspaceId, + actorKind: 'user', + actorId: actor.userId, + eventType: 'onboarding.profile_saved', + entityKind: 'offering', + entityId: saved.offeringId, + detail: { name: parsed.data.offering.name }, + }); + + return c.json({ saved: true, ...saved }); + }); + + /** What the workspace currently believes about itself. */ + api.get('/onboarding/profile', async (c) => { + const actor = c.get('actor'); + const profile = await loadWorkspaceProfile(c.get('db'), actor.workspaceId); + return c.json(profile); + }); + // ------------------------------------------------------------ by url /** * Adds prospects from company URLs (PRD §8, URL-first intake). @@ -1164,11 +1287,15 @@ function numberOr(value: unknown, fallback: number): number { * A service token has no user, so this cannot apply to it; machine callers * are authorised by holding the token. */ -async function requireVerifiedEmail(db: Client, actor: RequestActor): Promise { +async function requireVerifiedEmail( + db: Client, + actor: RequestActor, + action = 'sending anything', +): Promise { if (actor.userId === 'usr_service') return; if (await isEmailVerified(db, actor.userId)) return; - throw new ApiError(403, 'email_unverified', 'confirm your email address before sending anything'); + throw new ApiError(403, 'email_unverified', `confirm your email address before ${action}`); } /** diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts new file mode 100644 index 0000000..a843b6d --- /dev/null +++ b/apps/api/src/onboarding.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Hono } from 'hono'; +import { StubModel } from '@outreachgraph/ai'; +import { SiteProvider, type FetchLike } from '@outreachgraph/providers'; +import { queryOne } from '@outreachgraph/db'; +import { createApp } from './app'; +import type { AppEnv, RequestActor } from './context'; +import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; + +const ACTOR: RequestActor = { + userId: SEED.userId, + workspaceId: SEED.workspaceId, + organizationId: SEED.organizationId, + role: 'owner', +}; + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +const SITE_HTML = `Loopwright +

Reliability tooling for agent teams

+

Catch agent regressions before your customers do.

+`; + +function stubNetwork(html = SITE_HTML): FetchLike { + return async (input) => + input.toString().endsWith('/robots.txt') + ? new Response('', { headers: { 'content-type': 'text/plain' } }) + : new Response(html, { headers: { 'content-type': 'text/html' } }); +} + +const GOOD_DRAFT = JSON.stringify({ + offering: { + name: 'Loopwright', + category: 'developer tooling', + description: 'Reliability tooling for agent teams.', + valuePropositions: ['Catch regressions early'], + likelyPains: ['Agents fail silently in production'], + competitors: [], + }, + icp: { + titles: ['Staff Engineer', 'Head of Platform'], + seniorities: ['senior'], + industries: ['software'], + technologies: ['python'], + keywords: ['agent reliability'], + exclusions: ['students'], + }, + voice: { style: 'direct and technical', instructions: 'no hype', maxWords: 120 }, + whereToFind: ['GitHub issues on agent frameworks — people describe failures there.'], +}); + +async function harness( + label: string, + options: { actor?: RequestActor | null; model?: StubModel; verified?: boolean } = {}, +): Promise<{ app: Hono; seeded: SeededDatabase }> { + const seeded = await seedDatabase(label); + active = seeded; + + if (options.verified === false) { + await seeded.db.execute({ + sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?', + args: [SEED.userId], + }); + } + + const app = createApp({ + db: seeded.db, + authenticate: async () => options.actor ?? (options.actor === null ? undefined : ACTOR), + site: new SiteProvider({ fetchImpl: stubNetwork() }), + ...(options.model ? { model: options.model } : {}), + }); + + return { app, seeded }; +} + +async function post(app: Hono, path: string, body: unknown): Promise { + return app.request(`/api/v1${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +async function put(app: Hono, path: string, body: unknown): Promise { + return app.request(`/api/v1${path}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /onboarding/profile', () => { + test('an unverified email cannot spend a model call', async () => { + const model = new StubModel(GOOD_DRAFT); + const { app } = await harness('onboard-unverified', { model, verified: false }); + + const response = await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe('email_unverified'); + // The gate has to sit in front of the spend, not merely in front of the + // reply: an unverified signup that still burned a call would be an open + // invitation to do it a thousand times. + expect(model.calls).toHaveLength(0); + }); + + test('a verified caller gets a draft back', async () => { + const model = new StubModel(GOOD_DRAFT); + const { app } = await harness('onboard-draft', { model }); + + const response = await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.draft.offering.name).toBe('Loopwright'); + expect(body.draft.icp.titles).toContain('Staff Engineer'); + expect(body.draft.whereToFind.length).toBeGreaterThan(0); + }); + + test('nothing is written until the draft is confirmed', async () => { + const model = new StubModel(GOOD_DRAFT); + const { app, seeded } = await harness('onboard-no-write', { model }); + + await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + + const offering = await queryOne<{ name: string }>( + seeded.db, + 'SELECT name FROM offerings WHERE workspace_id = ? ORDER BY created_at LIMIT 1', + [SEED.workspaceId], + ); + + // A model's reading of a marketing page is a suggestion. Adopting it + // silently would ground every later draft in something nobody checked. + expect(offering?.name).not.toBe('Loopwright'); + }); + + test('without a model configured it says so rather than failing opaquely', async () => { + const { app } = await harness('onboard-nomodel'); + + const response = await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + expect(response.status).toBe(503); + expect((await response.json()).error.message).toContain('by hand'); + }); + + test('a site we cannot read is explained in terms of the site', async () => { + const model = new StubModel(GOOD_DRAFT); + const seeded = await seedDatabase('onboard-blocked'); + active = seeded; + + const app = createApp({ + db: seeded.db, + authenticate: async () => ACTOR, + model, + site: new SiteProvider({ + fetchImpl: async (input) => + input.toString().endsWith('/robots.txt') + ? new Response('User-agent: *\nDisallow: /', { + headers: { 'content-type': 'text/plain' }, + }) + : new Response(''), + }), + }); + + const response = await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + expect(response.status).toBe(400); + expect((await response.json()).error.message).toContain('robots.txt'); + }); + + test('an unparseable model reply is a 502, not a saved profile', async () => { + const model = new StubModel('I am not JSON'); + const { app } = await harness('onboard-badreply', { model }); + + const response = await post(app, '/onboarding/profile', { url: 'loopwright.io' }); + expect(response.status).toBe(502); + }); +}); + +describe('PUT /onboarding/profile', () => { + const PROFILE = { + url: 'https://loopwright.io', + offering: { + name: 'Loopwright', + category: 'developer tooling', + description: 'Reliability tooling.', + valuePropositions: ['Catch regressions'], + likelyPains: ['Silent failures'], + competitors: [], + }, + icp: { + titles: ['Staff Engineer'], + seniorities: [], + industries: ['software'], + technologies: [], + keywords: ['agent reliability'], + exclusions: [], + }, + voice: { style: 'direct', instructions: 'no hype', maxWords: 120 }, + }; + + test('a confirmed profile replaces the placeholder offering', async () => { + const { app, seeded } = await harness('onboard-save'); + + const response = await put(app, '/onboarding/profile', PROFILE); + expect(response.status).toBe(200); + + const offering = await queryOne<{ name: string; likely_pains: string }>( + seeded.db, + 'SELECT name, likely_pains FROM offerings WHERE workspace_id = ? ORDER BY created_at LIMIT 1', + [SEED.workspaceId], + ); + + expect(offering?.name).toBe('Loopwright'); + expect(offering?.likely_pains).toContain('Silent failures'); + }); + + test('the ICP lands on the campaign the pipeline actually reads', async () => { + const { app, seeded } = await harness('onboard-filters'); + + await put(app, '/onboarding/profile', PROFILE); + + const filters = await queryOne<{ titles: string; keywords: string }>( + seeded.db, + `SELECT f.titles, f.keywords FROM campaign_filters f + JOIN campaigns c ON c.id = f.campaign_id + WHERE c.workspace_id = ? LIMIT 1`, + [SEED.workspaceId], + ); + + expect(filters?.titles).toContain('Staff Engineer'); + expect(filters?.keywords).toContain('agent reliability'); + }); + + test('saving twice corrects the profile rather than creating a rival', async () => { + const { app, seeded } = await harness('onboard-twice'); + + await put(app, '/onboarding/profile', PROFILE); + await put(app, '/onboarding/profile', { + ...PROFILE, + offering: { ...PROFILE.offering, name: 'Loopwright v2' }, + }); + + const rows = await seeded.db.execute({ + sql: 'SELECT COUNT(*) AS n FROM offerings WHERE workspace_id = ?', + args: [SEED.workspaceId], + }); + + expect(Number(rows.rows[0]!.n)).toBe(1); + }); + + test('an incomplete profile is rejected', async () => { + const { app } = await harness('onboard-invalid'); + + const response = await put(app, '/onboarding/profile', { offering: { name: '' } }); + expect(response.status).toBe(400); + }); + + test('GET reports an unconfigured workspace as unconfigured', async () => { + const { app } = await harness('onboard-get'); + + const before = await (await app.request('/api/v1/onboarding/profile')).json(); + expect(before.configured).toBe(false); + + await put(app, '/onboarding/profile', PROFILE); + + const after = await (await app.request('/api/v1/onboarding/profile')).json(); + expect(after.configured).toBe(true); + expect(after.offering.name).toBe('Loopwright'); + }); +}); diff --git a/apps/api/src/workspace-profile.ts b/apps/api/src/workspace-profile.ts new file mode 100644 index 0000000..1849121 --- /dev/null +++ b/apps/api/src/workspace-profile.ts @@ -0,0 +1,267 @@ +/** + * The workspace's own profile: what you sell, who buys it, how you write. + * + * Three tables that already existed and nothing ever filled — `offerings`, + * `campaign_filters` and `voice_profiles`. Until now the only thing writing + * them was the test seed, and the default campaign bootstrapped an offering + * named "Your offering" with placeholder text that every draft was then + * grounded in. + * + * Upserts rather than inserts throughout: this is a setup step people re-run + * after changing their site or getting the ICP wrong the first time, and a + * second run must correct the profile rather than create a rival one. + */ + +import { newId } from '@outreachgraph/domain'; +import { now, queryOne, type Client } from '@outreachgraph/db'; +import type { WorkspaceProfile } from '@outreachgraph/contracts'; + +export interface SavedProfile { + readonly offeringId: string; + readonly voiceProfileId: string; + readonly campaignId: string; +} + +/** JSON columns hold arrays; this keeps the encoding in one place. */ +function list(values: readonly string[] | undefined): string { + return JSON.stringify(values ?? []); +} + +function parseList(value: unknown): string[] { + if (typeof value !== 'string') return []; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } +} + +export async function saveWorkspaceProfile( + db: Client, + workspaceId: string, + profile: WorkspaceProfile, +): Promise { + const stamp = now(); + + const existingOffering = await queryOne<{ id: string }>( + db, + 'SELECT id FROM offerings WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1', + [workspaceId], + ); + + const offeringId = existingOffering?.id ?? newId('offering'); + + if (existingOffering) { + await db.execute({ + sql: `UPDATE offerings SET name = ?, category = ?, url = ?, description = ?, + value_propositions = ?, likely_pains = ?, competitors = ?, updated_at = ? + WHERE id = ?`, + args: [ + profile.offering.name, + profile.offering.category, + profile.url ?? null, + profile.offering.description ?? null, + list(profile.offering.valuePropositions), + list(profile.offering.likelyPains), + list(profile.offering.competitors), + stamp, + offeringId, + ], + }); + } else { + await db.execute({ + sql: `INSERT INTO offerings (id, workspace_id, name, category, url, description, + value_propositions, likely_pains, competitors, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + offeringId, + workspaceId, + profile.offering.name, + profile.offering.category, + profile.url ?? null, + profile.offering.description ?? null, + list(profile.offering.valuePropositions), + list(profile.offering.likelyPains), + list(profile.offering.competitors), + stamp, + stamp, + ], + }); + } + + const existingVoice = await queryOne<{ id: string }>( + db, + 'SELECT id FROM voice_profiles WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1', + [workspaceId], + ); + + const voiceProfileId = existingVoice?.id ?? newId('voiceProfile'); + + if (existingVoice) { + await db.execute({ + sql: `UPDATE voice_profiles SET style = ?, instructions = ?, max_words = ?, updated_at = ? + WHERE id = ?`, + args: [ + profile.voice.style, + profile.voice.instructions ?? null, + profile.voice.maxWords ?? null, + stamp, + voiceProfileId, + ], + }); + } else { + await db.execute({ + sql: `INSERT INTO voice_profiles (id, workspace_id, name, style, instructions, max_words, + created_at, updated_at) + VALUES (?, ?, 'Default', ?, ?, ?, ?, ?)`, + args: [ + voiceProfileId, + workspaceId, + profile.voice.style, + profile.voice.instructions ?? null, + profile.voice.maxWords ?? null, + stamp, + stamp, + ], + }); + } + + // The campaign is what joins the three together, and it is what the pipeline + // reads. A workspace whose profile is set but whose campaign still points at + // the placeholder offering would draft against text nobody wrote. + const existingCampaign = await queryOne<{ id: string }>( + db, + `SELECT id FROM campaigns WHERE workspace_id = ? + ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, created_at ASC LIMIT 1`, + [workspaceId], + ); + + const campaignId = existingCampaign?.id ?? newId('campaign'); + + if (existingCampaign) { + await db.execute({ + sql: `UPDATE campaigns SET offering_id = ?, voice_profile_id = ?, updated_at = ? WHERE id = ?`, + args: [offeringId, voiceProfileId, stamp, campaignId], + }); + } else { + await db.execute({ + sql: `INSERT INTO campaigns (id, workspace_id, name, offering_id, voice_profile_id, + approval_mode, status, created_at, updated_at, started_at) + VALUES (?, ?, 'First campaign', ?, ?, 'draft_and_approve', 'active', ?, ?, ?)`, + args: [campaignId, workspaceId, offeringId, voiceProfileId, stamp, stamp, stamp], + }); + } + + await db.execute({ + sql: `INSERT INTO campaign_filters (campaign_id, titles, seniorities, industries, + technologies, keywords, exclusions, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(campaign_id) DO UPDATE SET + titles = excluded.titles, seniorities = excluded.seniorities, + industries = excluded.industries, technologies = excluded.technologies, + keywords = excluded.keywords, exclusions = excluded.exclusions, + updated_at = excluded.updated_at`, + args: [ + campaignId, + list(profile.icp.titles), + list(profile.icp.seniorities), + list(profile.icp.industries), + list(profile.icp.technologies), + list(profile.icp.keywords), + list(profile.icp.exclusions), + stamp, + ], + }); + + return { offeringId, voiceProfileId, campaignId }; +} + +export interface LoadedProfile { + readonly configured: boolean; + readonly url?: string; + readonly offering?: WorkspaceProfile['offering']; + readonly icp?: WorkspaceProfile['icp']; + readonly voice?: WorkspaceProfile['voice']; +} + +export async function loadWorkspaceProfile( + db: Client, + workspaceId: string, +): Promise { + const offering = await queryOne<{ + id: string; + name: string; + category: string; + url: string | null; + description: string | null; + value_propositions: string; + likely_pains: string; + competitors: string; + }>( + db, + `SELECT id, name, category, url, description, value_propositions, likely_pains, competitors + FROM offerings WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1`, + [workspaceId], + ); + + const voice = await queryOne<{ + style: string; + instructions: string | null; + max_words: number | null; + }>( + db, + `SELECT style, instructions, max_words FROM voice_profiles + WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1`, + [workspaceId], + ); + + // A voice profile is the signal, because only completing setup creates one — + // the campaign bootstrap writes a placeholder offering but never a voice. + // Keying off the offering instead meant comparing its name to the literal + // string "Your offering", which is the kind of check that silently stops + // working the day somebody edits the bootstrap copy. + if (!offering || !voice) return { configured: false }; + + const filters = await queryOne<{ + titles: string; + seniorities: string; + industries: string; + technologies: string; + keywords: string; + exclusions: string; + }>( + db, + `SELECT f.titles, f.seniorities, f.industries, f.technologies, f.keywords, f.exclusions + FROM campaign_filters f + JOIN campaigns c ON c.id = f.campaign_id + WHERE c.workspace_id = ? ORDER BY c.created_at ASC LIMIT 1`, + [workspaceId], + ); + + return { + configured: true, + ...(offering.url ? { url: offering.url } : {}), + offering: { + name: offering.name, + category: offering.category, + ...(offering.description ? { description: offering.description } : {}), + valuePropositions: parseList(offering.value_propositions), + likelyPains: parseList(offering.likely_pains), + competitors: parseList(offering.competitors), + }, + icp: { + titles: parseList(filters?.titles), + seniorities: parseList(filters?.seniorities), + industries: parseList(filters?.industries), + technologies: parseList(filters?.technologies), + keywords: parseList(filters?.keywords), + exclusions: parseList(filters?.exclusions), + }, + voice: { + style: voice.style, + ...(voice.instructions ? { instructions: voice.instructions } : {}), + ...(voice.max_words ? { maxWords: voice.max_words } : {}), + }, + }; +} diff --git a/apps/web/app/(app)/setup/page.tsx b/apps/web/app/(app)/setup/page.tsx new file mode 100644 index 0000000..c52a5d5 --- /dev/null +++ b/apps/web/app/(app)/setup/page.tsx @@ -0,0 +1,65 @@ +import { redirect } from 'next/navigation'; +import { ProfileSetup } from '../../../components/profile-setup'; +import { + ApiUnavailableError, + NotAuthenticatedError, + fetchMe, + fetchProfile, +} from '../../../lib/api'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { title: 'Set up · OutreachGraph' }; + +/** + * The setup step (PRD §7, §11). + * + * Everything downstream is grounded in what this page collects: the composer + * writes from the offering, the scorer ranks against the ICP. Until it is + * filled in, the workspace is pointed at a placeholder offering that reads + * "Describe what you sell here" — which is what every draft would quote. + */ +export default async function SetupPage() { + let me; + let profile; + let offline = false; + + try { + [me, profile] = await Promise.all([fetchMe(), fetchProfile()]); + } catch (error) { + if (error instanceof NotAuthenticatedError) redirect('/login'); + if (error instanceof ApiUnavailableError) offline = true; + else throw error; + } + + const verified = me?.emailVerified ?? false; + + return ( +
+
+

Set up your profile

+

+ {profile?.configured + ? `Currently set to “${profile.offering?.name}”. Reading your site again replaces it.` + : 'Outreach is only as good as what we know about you. This takes a minute.'} +

+
+ + {offline ? ( +

+ Waiting for the API. +

+ ) : !verified ? ( + // Reading a site costs a model call, and an unverified signup is an + // unattributed one. The gate is enforced by the API; this explains it + // rather than letting the button fail with a 403. +

+ Confirm your email address first — we check it before running anything that costs money. + The link is in your inbox. +

+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/(app)/today/page.tsx b/apps/web/app/(app)/today/page.tsx index 25468b5..2122d5b 100644 --- a/apps/web/app/(app)/today/page.tsx +++ b/apps/web/app/(app)/today/page.tsx @@ -6,6 +6,7 @@ import { NotAuthenticatedError, fetchApprovals, fetchMe, + fetchProfile, fetchSignals, relativeTime, } from '../../../lib/api'; @@ -24,10 +25,16 @@ export default async function TodayPage() { let approvals: Awaited> = []; let signals: Awaited> = []; let me: Awaited> | undefined; + let profile: Awaited> | undefined; let offline = false; try { - [approvals, signals, me] = await Promise.all([fetchApprovals(), fetchSignals(), fetchMe()]); + [approvals, signals, me, profile] = await Promise.all([ + fetchApprovals(), + fetchSignals(), + fetchMe(), + fetchProfile(), + ]); } catch (error) { if (error instanceof NotAuthenticatedError) redirect('/login'); if (error instanceof ApiUnavailableError) offline = true; @@ -40,6 +47,21 @@ export default async function TodayPage() {
{me && !me.emailVerified ? : null} + {/* Until this is done every draft is grounded in placeholder text, so it + is worth interrupting for once rather than hiding in a settings page. */} + {me?.emailVerified && profile && !profile.configured ? ( + + Tell us what you sell + + Paste your website and we will draft your profile — what you sell, who buys it, and + where to find them. Outreach is grounded in it. + + + ) : null} +

Today

diff --git a/apps/web/components/profile-setup.tsx b/apps/web/components/profile-setup.tsx new file mode 100644 index 0000000..e6cd5d9 --- /dev/null +++ b/apps/web/components/profile-setup.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, type FormEvent } from 'react'; + +/** + * Setup: paste your own site, get a profile back, correct it, save it. + * + * Deliberately a review step rather than a save. What comes back is a model's + * reading of a marketing page — a good starting point and not a fact about + * somebody's business — so it is shown as editable fields and nothing is + * written until the person presses save. The alternative, silently adopting + * whatever the model said, means every draft afterwards is grounded in a guess + * nobody checked. + */ + +interface Draft { + offering: { + name: string; + category: string; + description: string; + valuePropositions: string[]; + likelyPains: string[]; + competitors: string[]; + }; + icp: { + titles: string[]; + seniorities: string[]; + industries: string[]; + technologies: string[]; + keywords: string[]; + exclusions: string[]; + }; + voice: { style: string; instructions: string; maxWords: number }; + whereToFind: string[]; +} + +const lines = (values: string[]) => values.join('\n'); +const parseLines = (value: string) => + value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + +export function ProfileSetup({ initialUrl }: { initialUrl?: string }) { + const router = useRouter(); + const [url, setUrl] = useState(initialUrl ?? ''); + const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const [draft, setDraft] = useState(); + const [saved, setSaved] = useState(false); + + async function read(event: FormEvent) { + event.preventDefault(); + if (!url.trim()) return; + + setBusy(true); + setError(undefined); + setSaved(false); + + try { + const response = await fetch('/api/v1/onboarding/profile', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ url }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + setError(payload?.error?.message ?? `that failed (${response.status})`); + return; + } + + setDraft(payload.draft as Draft); + } catch { + setError('could not reach the server'); + } finally { + setBusy(false); + } + } + + async function save() { + if (!draft) return; + setSaving(true); + setError(undefined); + + try { + const response = await fetch('/api/v1/onboarding/profile', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + url, + offering: draft.offering, + icp: draft.icp, + voice: draft.voice, + }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + setError(payload?.error?.message ?? `that failed (${response.status})`); + return; + } + + setSaved(true); + router.refresh(); + } catch { + setError('could not reach the server'); + } finally { + setSaving(false); + } + } + + function edit(patch: Partial) { + if (!draft) return; + setDraft({ ...draft, ...patch }); + } + + return ( +

+
+ +

+ We read your homepage and draft a profile: what you sell, who buys it, and how you write. + You get to correct all of it before anything is saved. +

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

+ {error} +

+ ) : null} +
+ + {draft ? ( +
+ edit({ offering: { ...draft.offering, name } })} + /> + +