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 } })} + /> + +