diff --git a/infra/console.ts b/infra/console.ts index c55d0b8bf583..7e33cd369c07 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL") +const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN") +const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", { + properties: { value: `https://${domain}/console/api/internal/console-account` }, +}) const logProcessor = new sst.cloudflare.Worker("LogProcessor", { handler: "packages/console/function/src/log-processor.ts", @@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", { SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, SALESFORCE_INSTANCE_URL, + CONSOLE_CRM_TOKEN, + CONSOLE_CRM_LOOKUP_URL, ], environment: { //VITE_DOCS_URL: web.url.apply((url) => url!), diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6131456dd672..860aa9179b5d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -6,6 +6,7 @@ "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite dev --host 0.0.0.0", + "dev:enterprise": "bun --watch script/dev-enterprise.ts", "dev:remote": "bun sst shell --stage=dev bun dev", "build": "bun ./script/generate-sitemap.ts && vite build", "start": "vite start" diff --git a/packages/console/app/script/dev-enterprise.ts b/packages/console/app/script/dev-enterprise.ts new file mode 100644 index 000000000000..b7f9526f4722 --- /dev/null +++ b/packages/console/app/script/dev-enterprise.ts @@ -0,0 +1,99 @@ +import { handleEnterpriseRequest } from "../src/routes/api/enterprise" +import { z } from "zod" +import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response" +import { handleConsoleSignup } from "../src/routes/api/console-signup" +import { consoleAccountSchema } from "../src/lib/console-account" + +const org = process.env.SF_TARGET_ORG || "opencode" +const crmToken = process.env.CONSOLE_CRM_TOKEN +if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token") +const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account" + +async function authenticatedRequest( + path: string, + payload?: Record, + method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET", +) { + const child = Bun.spawn( + [ + "sf", + "api", + "request", + "rest", + path, + "--target-org", + org, + "--json", + "--method", + method, + ...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []), + ], + { + stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ) + const [output] = await Promise.all([ + new Response(child.stdout).text(), + child.exited, + new Response(child.stderr).text(), + ]) + return salesforceCliResponse(output) +} + +await authenticatedRequest("/services/data/v59.0/") + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: Number(process.env.ENTERPRISE_DEV_PORT || 3101), + async fetch(request) { + const path = new URL(request.url).pathname + if (path === "/health" && request.method === "GET") { + return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org }) + } + if (path === "/api/console-signup" && request.method === "POST") { + return handleConsoleSignup(request, crmToken, (path, method, payload) => + authenticatedRequest(path, payload, method), + ) + } + if (path !== "/api/enterprise") return new Response("Not found", { status: 404 }) + if (request.method !== "POST") return new Response("Method not allowed", { status: 405 }) + const failures: string[] = [] + const response = await handleEnterpriseRequest(request, { + salesforceSubmit: async (payload) => { + const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch( + (error: unknown) => { + const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR" + failures.push(code) + console.error(`Local Salesforce submission rejected: ${code}`) + return undefined + }, + ) + return z.object({ success: z.literal(true) }).safeParse(result).success + }, + notifications: false, + accountLookup: async (email) => { + const response = await fetch(accountUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` }, + body: JSON.stringify({ email }), + signal: AbortSignal.timeout(8000), + }) + if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`) + return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account + }, + }) + if (failures.length > 0) { + return Response.json( + { error: "Salesforce rejected the request.", errorCode: failures[0] }, + { status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } }, + ) + } + response.headers.set("X-Enterprise-Handler", "local-sst-enterprise") + return response + }, +}) + +console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`) +console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`) diff --git a/packages/console/app/script/salesforce-cli-response.test.ts b/packages/console/app/script/salesforce-cli-response.test.ts new file mode 100644 index 000000000000..9f07243a47de --- /dev/null +++ b/packages/console/app/script/salesforce-cli-response.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response" + +test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => { + expect( + salesforceCliResponse( + JSON.stringify({ + status: 0, + result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } }, + }), + ), + ).toEqual({ id: "test-lead", success: true, errors: [] }) +}) + +test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => { + expect(() => + salesforceCliResponse( + JSON.stringify({ + status: 0, + result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] }, + }), + ), + ).toThrow() +}) + +test("rejects CLI errors and responses missing the HTTP envelope", () => { + expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow() + expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow() +}) + +test("preserves the Salesforce rejection code when the CLI exits nonzero", () => { + expect(() => + salesforceCliResponse( + JSON.stringify({ + status: 1, + result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] }, + }), + ), + ).toThrow(new SalesforceCliError("DUPLICATES_DETECTED")) +}) diff --git a/packages/console/app/script/salesforce-cli-response.ts b/packages/console/app/script/salesforce-cli-response.ts new file mode 100644 index 000000000000..3ddceeb1d435 --- /dev/null +++ b/packages/console/app/script/salesforce-cli-response.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +export class SalesforceCliError extends Error { + constructor(readonly code: string) { + super(`Salesforce request failed: ${code}`) + } +} + +export function salesforceCliResponse(output: string) { + const response = z + .object({ + status: z.number(), + result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(), + }) + .parse(JSON.parse(output)) + if (!response.result) throw new SalesforceCliError("CLI_ERROR") + if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) { + const errors = z + .array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) })) + .safeParse(response.result.body) + throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED") + } + return response.result.body +} diff --git a/packages/console/app/src/lib/console-account.ts b/packages/console/app/src/lib/console-account.ts new file mode 100644 index 000000000000..7636c5e35f7a --- /dev/null +++ b/packages/console/app/src/lib/console-account.ts @@ -0,0 +1,31 @@ +import { Resource } from "@opencode/console-resource" +import { z } from "zod" + +export const consoleAccountSchema = z.object({ + id: z.string().min(1).max(255), + email: z.email(), + name: z.string().nullable(), + createdAt: z.iso.datetime(), +}) +export type ConsoleAccount = z.infer + +export async function lookupConsoleAccount(email: string): Promise { + const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` }, + body: JSON.stringify({ email }), + signal: AbortSignal.timeout(3000), + }) + if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`) + const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()) + return result.account +} + +export function consoleAccountFields(account: ConsoleAccount | null) { + if (!account) return { Has_Console_Account__c: false } + return { + Has_Console_Account__c: true, + Console_Account_ID__c: account.id, + Console_Signup_Date__c: account.createdAt, + } +} diff --git a/packages/console/app/src/lib/console-signup.test.ts b/packages/console/app/src/lib/console-signup.test.ts new file mode 100644 index 000000000000..acc7c0c144a3 --- /dev/null +++ b/packages/console/app/src/lib/console-signup.test.ts @@ -0,0 +1,159 @@ +import { afterEach, expect, test } from "bun:test" +import { syncConsoleAccount, type SalesforceRequest } from "./salesforce" +import { handleConsoleSignup } from "../routes/api/console-signup" +import { z } from "zod" + +const account = { + id: "user_console_test", + email: "person@example.test", + name: "Test Person", + createdAt: "2026-10-01T10:00:00.000Z", +} +const servers = new Set>() +afterEach(async () => { + await Promise.all([...servers].map((server) => server.stop(true))) + servers.clear() +}) + +function fixture( + initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [], + email = account.email, +) { + const people: Record[] = initial.map((person) => ({ ...person })) + const writes: { method: string; path: string; payload: Record }[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname.endsWith("/query")) { + const query = url.searchParams.get("q") ?? "" + expect(query).toContain("Console_Account_ID__c") + expect(query).toContain(`Email = '${email.toLowerCase()}'`) + const object = query.includes("FROM Contact") ? "Contact" : "Lead" + if (object === "Lead") expect(query).toContain("IsConverted = false") + return Response.json({ + done: true, + records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })), + }) + } + const payload = z.record(z.string(), z.unknown()).parse(await request.json()) + writes.push({ method: request.method, path: url.pathname, payload }) + if (request.method === "PATCH") { + const person = people.find((person) => person.Id === url.pathname.split("/").at(-1)) + if (!person) return Response.json({ error: "Not found" }, { status: 404 }) + Object.assign(person, payload) + return new Response(null, { status: 204 }) + } + const id = "00Q000000000001AAA" + people.push({ object: "Lead", Id: id, ...payload }) + return Response.json({ success: true, id }, { status: 201 }) + }, + }) + servers.add(server) + const request: SalesforceRequest = async (path, method = "GET", payload) => { + const response = await fetch(new URL(path, server.url), { + method, + ...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}), + }) + if (!response.ok) throw new Error("CRM request failed") + return response.status === 204 ? null : response.json() + } + return { people, writes, request } +} + +test("a new signup creates one Console lead and an exact retry updates it", async () => { + const crm = fixture() + await syncConsoleAccount(account, crm.request) + await syncConsoleAccount(account, crm.request) + expect(crm.people).toHaveLength(1) + expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1) + expect(crm.people[0]).toMatchObject({ + Company: "example.test", + LeadSource: "console signup", + Has_Console_Account__c: true, + Console_Account_ID__c: account.id, + Console_Signup_Date__c: account.createdAt, + }) + expect(crm.writes[1].payload).not.toHaveProperty("LeadSource") + expect(crm.writes[1].payload).not.toHaveProperty("Company") +}) + +test.each([ + ["hello@acme.com", "acme.com"], + ["jane@company.co.uk", "company.co.uk"], + ["HELLO@ACME.COM", "acme.com"], + ["person@gmail.com", "Individual"], + ["person+signup@GOOGLEMAIL.COM", "Individual"], + ["person@outlook.com", "Individual"], + ["person@hotmail.co.uk", "Individual"], + ["person@yahoo.com", "Individual"], + ["person@icloud.com", "Individual"], + ["person@proton.me", "Individual"], + ["person@protonmail.com", "Individual"], + ["person@pm.me", "Individual"], + ["person@aol.com", "Individual"], + ["person@gmail.com.example.test", "gmail.com.example.test"], +])("sets Company for a new signup with %s to %s", async (email, company) => { + const crm = fixture([], email) + await syncConsoleAccount({ ...account, email }, crm.request) + expect(crm.people).toHaveLength(1) + expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" }) +}) + +test("matching Leads and Contacts keep their source and receive the Console fields", async () => { + const crm = fixture([ + { object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" }, + { object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" }, + ]) + await syncConsoleAccount(account, crm.request) + expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true) + expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"]) + expect(crm.people[0].AccountId).toBe("001000000000001AAA") + expect(crm.people[1].Company).toBe("Visitor's Company") + for (const write of crm.writes) { + expect(write.payload).not.toHaveProperty("Company") + expect(write.payload).not.toHaveProperty("AccountId") + } + for (const person of crm.people) + expect(person).toMatchObject({ + Has_Console_Account__c: true, + Console_Account_ID__c: account.id, + Console_Signup_Date__c: account.createdAt, + }) +}) + +test("a converted person's Contact is updated without creating another Lead", async () => { + const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }]) + await syncConsoleAccount(account, crm.request) + expect(crm.people).toHaveLength(1) + expect(crm.writes[0].path).toContain("/Contact/") + expect(crm.people[0].LeadSource).toBe("Website") +}) + +test("the signup endpoint requires its server credential before touching Salesforce", async () => { + const crm = fixture() + const response = await handleConsoleSignup( + new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }), + "test-shared-token", + crm.request, + ) + expect(response.status).toBe(401) + expect(crm.writes).toHaveLength(0) +}) + +test("the authenticated signup endpoint reports confirmed delivery", async () => { + const crm = fixture() + const response = await handleConsoleSignup( + new Request("http://localhost/api/console-signup", { + method: "POST", + headers: { Authorization: "Bearer test-shared-token" }, + body: JSON.stringify(account), + }), + "test-shared-token", + crm.request, + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ success: true }) + expect(crm.people).toHaveLength(1) +}) diff --git a/packages/console/app/src/lib/salesforce.test.ts b/packages/console/app/src/lib/salesforce.test.ts new file mode 100644 index 000000000000..219a2ab33d37 --- /dev/null +++ b/packages/console/app/src/lib/salesforce.test.ts @@ -0,0 +1,112 @@ +import { afterAll, expect, test } from "bun:test" + +const leads: Record[] = [] +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request, instance) { + const path = new URL(request.url).pathname + if (path === "/services/oauth2/token") { + return Response.json({ access_token: "local-test-token", instance_url: instance.url.origin }) + } + if (path === "/services/data/v59.0/sobjects/Lead" && request.method === "POST") { + leads.push(await request.json()) + return Response.json({ id: "local-test-lead", success: true }, { status: 201 }) + } + return new Response("Not found", { status: 404 }) + }, +}) + +const resources = { + SST_RESOURCE_SALESFORCE_INSTANCE_URL: server.url.origin, + SST_RESOURCE_SALESFORCE_CLIENT_ID: "local-test-client", + SST_RESOURCE_SALESFORCE_CLIENT_SECRET: "local-test-secret", +} +const previous = Object.fromEntries(Object.keys(resources).map((key) => [key, process.env[key]])) +Object.entries(resources).forEach(([key, value]) => { + process.env[key] = JSON.stringify({ value }) +}) + +afterAll(async () => { + await server.stop(true) + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value + }) +}) + +test("a fake entry keeps the visitor message, spend range, and enterprise lead source in their own fields", async () => { + const { createLead } = await import("./salesforce") + const accepted = await createLead({ + name: "Fake Console Visitor", + role: "Engineering lead", + company: "Example Test Company", + email: "fake-visitor@example.test", + inferenceSpend: "$10K–$50K", + message: " We need SSO for our developers.\nPlease share the enterprise options. ", + }) + + expect(accepted).toBe(true) + expect(leads).toHaveLength(1) + expect(leads[0]).toEqual({ + LastName: "Fake Console Visitor", + Company: "Example Test Company", + Email: "fake-visitor@example.test", + Phone: null, + Title: "Engineering lead", + Description: " We need SSO for our developers.\nPlease share the enterprise options. ", + Current_Monthly_Inference_Spend__c: "$10K–$50K", + LeadSource: "enterprise website form", + }) +}) + +test("the local Enterprise request handler applies the updated mapping to a form submission", async () => { + const { handleEnterpriseRequest } = await import("../routes/api/enterprise") + const response = await handleEnterpriseRequest( + new Request("http://localhost/api/enterprise", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Fake Local Form Visitor", + role: "Engineering lead", + company: "Example Test Company", + email: "fake-local-visitor@example.test", + inferenceSpend: "50k-100k", + message: " How are you bro!\nThis is a local form test. ", + alias: "", + }), + }), + { + salesforceSubmit: async (payload) => { + const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + return response.ok + }, + notifications: false, + accountLookup: async () => ({ + id: "user_test", + email: "fake-local-visitor@example.test", + name: null, + createdAt: "2026-10-01T12:00:00.000Z", + }), + }, + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ success: true }) + expect(leads.at(-1)).toMatchObject({ + LastName: "Fake Local Form Visitor", + Description: " How are you bro!\nThis is a local form test. ", + Current_Monthly_Inference_Spend__c: "$50K–$100K", + LeadSource: "enterprise website form", + Has_Console_Account__c: true, + Console_Account_ID__c: "user_test", + Console_Signup_Date__c: "2026-10-01T12:00:00.000Z", + }) +}) diff --git a/packages/console/app/src/lib/salesforce.ts b/packages/console/app/src/lib/salesforce.ts index 92ee218a3177..6ffd395c8269 100644 --- a/packages/console/app/src/lib/salesforce.ts +++ b/packages/console/app/src/lib/salesforce.ts @@ -1,4 +1,54 @@ import { Resource } from "@opencode/console-resource" +import { z } from "zod" +import { consoleAccountFields, type ConsoleAccount } from "./console-account" + +// Match provider domains exactly; business domains hosted by these providers remain business addresses. +const personalEmailDomains = new Set([ + "gmail.com", + "googlemail.com", + "outlook.com", + "outlook.co.uk", + "hotmail.com", + "hotmail.co.uk", + "hotmail.fr", + "hotmail.de", + "hotmail.it", + "live.com", + "live.co.uk", + "live.ca", + "live.com.au", + "msn.com", + "yahoo.com", + "yahoo.co.uk", + "yahoo.co.in", + "yahoo.com.au", + "yahoo.ca", + "yahoo.fr", + "yahoo.de", + "yahoo.it", + "yahoo.co.jp", + "ymail.com", + "rocketmail.com", + "icloud.com", + "me.com", + "mac.com", + "proton.me", + "protonmail.com", + "pm.me", + "aol.com", + "aim.com", + "gmx.com", + "gmx.de", + "gmx.net", + "mail.com", + "fastmail.com", + "fastmail.fm", + "hey.com", + "tuta.com", + "tuta.io", + "tutanota.com", + "tutanota.de", +]) async function login() { const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "") @@ -46,9 +96,25 @@ export interface SalesforceLeadInput { phone?: string inferenceSpend?: string message: string + consoleAccount?: ConsoleAccount | null } -export async function createLead(input: SalesforceLeadInput): Promise { +export async function createLead( + input: SalesforceLeadInput, + submit?: (payload: Record) => Promise, +): Promise { + const payload = { + LastName: input.name, + Company: input.company?.trim() || "Website", + Email: input.email, + Phone: input.phone ?? null, + Title: input.role, + Description: input.message, + Current_Monthly_Inference_Spend__c: input.inferenceSpend, + LeadSource: "enterprise website form", + ...(input.consoleAccount === undefined ? {} : consoleAccountFields(input.consoleAccount)), + } + if (submit) return submit(payload) const auth = await login() if (!auth) return false @@ -58,18 +124,7 @@ export async function createLead(input: SalesforceLeadInput): Promise { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - LastName: input.name, - Company: input.company?.trim() || "Website", - Email: input.email, - Phone: input.phone ?? null, - Title: input.role, - Description: input.inferenceSpend - ? `Current monthly inference spend: ${input.inferenceSpend}\n\n${input.message}` - : input.message, - Current_Monthly_Inference_Spend__c: input.inferenceSpend, - LeadSource: "Website", - }), + body: JSON.stringify(payload), }).catch((err) => { console.error("Failed to create Salesforce lead:", err) }) @@ -83,3 +138,65 @@ export async function createLead(input: SalesforceLeadInput): Promise { return true } + +export type SalesforceRequest = ( + path: string, + method?: "GET" | "POST" | "PATCH", + payload?: Record, +) => Promise + +export async function salesforceRequest(): Promise { + const auth = await login() + if (!auth) throw new Error("Salesforce authentication unavailable") + return async (path, method = "GET", payload) => { + const response = await fetch(`${auth.url}${path}`, { + method, + headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" }, + ...(payload ? { body: JSON.stringify(payload) } : {}), + signal: AbortSignal.timeout(8000), + }) + if (!response.ok) throw new Error(`Salesforce request failed (${response.status})`) + if (response.status === 204) return null + return response.json() + } +} + +/** Update existing people without changing their acquisition source or sales notes. */ +export async function syncConsoleAccount(account: ConsoleAccount, request: SalesforceRequest) { + const quote = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + const matches = async (object: "Contact" | "Lead") => { + const query = `SELECT Id FROM ${object} WHERE ${object === "Lead" ? "IsConverted = false AND " : ""}(Console_Account_ID__c = '${quote(account.id)}' OR Email = '${quote(account.email.toLowerCase())}')` + const records: { Id: string }[] = [] + let path: string | undefined = `/services/data/v59.0/query?q=${encodeURIComponent(query)}` + while (path) { + const result = z + .object({ + records: z.array(z.object({ Id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/) })), + done: z.boolean(), + nextRecordsUrl: z.string().optional(), + }) + .parse(await request(path)) + records.push(...result.records) + if (result.done) break + if (!result.nextRecordsUrl?.startsWith("/services/data/")) throw new Error("Salesforce query pagination missing") + path = result.nextRecordsUrl + } + return records.map((record) => ({ object, id: record.Id })) + } + const people = [...(await matches("Contact")), ...(await matches("Lead"))] + const fields = consoleAccountFields(account) + for (const person of people) + await request(`/services/data/v59.0/sobjects/${person.object}/${person.id}`, "PATCH", fields) + if (people.length > 0) return { success: true, created: false, updated: people.length } + const domain = account.email.slice(account.email.lastIndexOf("@") + 1).toLowerCase() + const result = z.object({ success: z.literal(true), id: z.string() }).parse( + await request("/services/data/v59.0/sobjects/Lead", "POST", { + LastName: (account.name?.trim() || account.email).slice(0, 80), + Company: personalEmailDomains.has(domain) ? "Individual" : domain, + Email: account.email.toLowerCase(), + LeadSource: "console signup", + ...fields, + }), + ) + return { success: true, created: true, id: result.id } +} diff --git a/packages/console/app/src/routes/api/console-signup.ts b/packages/console/app/src/routes/api/console-signup.ts new file mode 100644 index 000000000000..4a1233b789ab --- /dev/null +++ b/packages/console/app/src/routes/api/console-signup.ts @@ -0,0 +1,25 @@ +import { timingSafeEqual } from "node:crypto" +import type { APIEvent } from "@solidjs/start/server" +import { Resource } from "@opencode/console-resource" +import { consoleAccountSchema } from "~/lib/console-account" +import { salesforceRequest, syncConsoleAccount, type SalesforceRequest } from "~/lib/salesforce" + +export async function POST(event: APIEvent) { + return handleConsoleSignup(event.request, Resource.CONSOLE_CRM_TOKEN.value) +} + +export async function handleConsoleSignup(request: Request, token: string, salesforce?: SalesforceRequest) { + const expected = Buffer.from(`Bearer ${token}`) + const actual = Buffer.from(request.headers.get("authorization") ?? "") + if (!token || expected.length !== actual.length || !timingSafeEqual(expected, actual)) + return new Response(null, { status: 401 }) + const data: unknown = await request.json().catch(() => null) + const account = consoleAccountSchema.safeParse(data) + if (!account.success) return Response.json({ error: "Invalid Console account" }, { status: 400 }) + return Promise.resolve(salesforce) + .then(async (client) => syncConsoleAccount(account.data, client ?? (await salesforceRequest()))) + .then( + (result) => Response.json(result), + () => Response.json({ error: "Salesforce sync unavailable" }, { status: 503 }), + ) +} diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts index 452164305405..a69abf2a2b80 100644 --- a/packages/console/app/src/routes/api/enterprise.ts +++ b/packages/console/app/src/routes/api/enterprise.ts @@ -5,6 +5,7 @@ import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { createLead } from "~/lib/salesforce" import { inferenceSpendOptions } from "~/lib/inference-spend" +import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account" interface EnterpriseFormData { name: string @@ -61,9 +62,20 @@ function subscribe(email: string, fullName: string) { } export async function POST(event: APIEvent) { - const dict = i18n(localeFromRequest(event.request)) + return handleEnterpriseRequest(event.request) +} + +export async function handleEnterpriseRequest( + request: Request, + options: { + salesforceSubmit?: (payload: Record) => Promise + notifications?: boolean + accountLookup?: (email: string) => Promise + } = {}, +) { + const dict = i18n(localeFromRequest(request)) try { - const body = (await event.request.json()) as EnterpriseFormData + const body = (await request.json()) as EnterpriseFormData const trap = typeof body.alias === "string" ? body.alias.trim() : "" if (trap) { @@ -84,6 +96,11 @@ export async function POST(event: APIEvent) { return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 }) } + const account = await (options.accountLookup ?? lookupConsoleAccount)(body.email).catch((err) => { + console.error("Failed to look up Console account:", err) + return undefined + }) + const emailContent = ` ${body.message}

${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}

` : ""} @@ -94,31 +111,37 @@ ${body.company ? `${body.company}
` : ""}${body.email}
${body.phone ? `${body.phone}
` : ""}`.trim() const [lead, mail, octopus] = await Promise.all([ - createLead({ - name: body.name, - role: body.role, - company: body.company, - email: body.email, - phone: body.phone, - inferenceSpend: inferenceSpend?.salesforceValue, - message: body.message, - }).catch((err) => { + createLead( + { + name: body.name, + role: body.role, + company: body.company, + email: body.email, + phone: body.phone, + inferenceSpend: inferenceSpend?.salesforceValue, + message: body.message, + consoleAccount: account, + }, + options.salesforceSubmit, + ).catch((err) => { console.error("Failed to create Salesforce lead:", err) return false }), - sendEmail({ - to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value, - subject: `Enterprise Inquiry from ${body.name}`, - body: emailContent, - replyTo: body.email, - }).then( - () => true, - (err) => { - console.error("Failed to send enterprise email:", err) - return false - }, - ), - subscribe(body.email, body.name), + options.notifications === false + ? Promise.resolve(false) + : sendEmail({ + to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value, + subject: `Enterprise Inquiry from ${body.name}`, + body: emailContent, + replyTo: body.email, + }).then( + () => true, + (err) => { + console.error("Failed to send enterprise email:", err) + return false + }, + ), + options.notifications === false ? Promise.resolve(false) : subscribe(body.email, body.name), ]) if (!lead && !mail && !octopus) { diff --git a/salesforce/README.md b/salesforce/README.md new file mode 100644 index 000000000000..d98a7dd98850 --- /dev/null +++ b/salesforce/README.md @@ -0,0 +1,38 @@ +# Console account identity in Salesforce + +This metadata adds the `console signup` Lead Source and these fields on both Lead +and Contact: + +- `Has_Console_Account__c` (checkbox) +- `Console_Account_ID__c` (non-unique external ID, so an inquiry Lead and a Contact may refer to the same user) +- `Console_Signup_Date__c` (account creation timestamp) + +It also adds a Console Account section to the existing Lead and Contact detail +layouts, grants field permissions through `Console_Account_Sync`, and maps the three +Lead fields to Contact on conversion. Existing records are not backfilled. + +New signup Leads use the lowercased email domain as Company (for example, `acme.com`). +Recognized personal-email domains use `Individual`. This is an exact-domain list in +`packages/console/app/src/lib/salesforce.ts`, not a legal-company-name lookup. +Signup sync preserves existing Lead companies and Contact account associations; +Enterprise inquiries use the company supplied by the visitor. + +Before applying to another org, retrieve and preserve that org's current LeadSource +values, layouts, and LeadConvertSettings; these checked-in files are the additive +changes prepared for the connected OpenCode org. + +From this directory, deployment uses the existing Salesforce CLI authentication: + +```sh +sf project deploy start --source-dir force-app --target-org opencode --wait 10 +sf org assign permset --name Console_Account_Sync --target-org opencode +``` + +The application integration uses the existing SST Salesforce credentials. Set the +same `CONSOLE_CRM_TOKEN` on the SST website and Console API. The SST website +links `CONSOLE_CRM_LOOKUP_URL` to the stage's private Console account lookup. + +For local development, run `bun run dev:enterprise` in `packages/console/app` with +`CONSOLE_CRM_TOKEN` set and `sf` on PATH. It serves the same Enterprise request and +signup-sync handlers on `127.0.0.1:3101`, using the CLI's authenticated transport. +It never sends local sales notifications or EmailOctopus subscriptions. diff --git a/salesforce/force-app/main/default/LeadConvertSettings/LeadConvertSettings.LeadConvertSetting-meta.xml b/salesforce/force-app/main/default/LeadConvertSettings/LeadConvertSettings.LeadConvertSetting-meta.xml new file mode 100644 index 000000000000..96d61d7166c0 --- /dev/null +++ b/salesforce/force-app/main/default/LeadConvertSettings/LeadConvertSettings.LeadConvertSetting-meta.xml @@ -0,0 +1,21 @@ + + + true + + Lead + + Has_Console_Account__c + Has_Console_Account__c + + + Console_Account_ID__c + Console_Account_ID__c + + + Console_Signup_Date__c + Console_Signup_Date__c + + Contact + + VisibleOptional + diff --git a/salesforce/force-app/main/default/layouts/Contact-Contact Layout.layout-meta.xml b/salesforce/force-app/main/default/layouts/Contact-Contact Layout.layout-meta.xml new file mode 100644 index 000000000000..b31622c488e5 --- /dev/null +++ b/salesforce/force-app/main/default/layouts/Contact-Contact Layout.layout-meta.xml @@ -0,0 +1,294 @@ + + + DisableCustomerPortal + DisablePartnerPortal + DisablePrmSuperUser + EnablePrmSuperUser + Share + + truetruetrue + + + ReadonlyHas_Console_Account__c + ReadonlyConsole_Signup_Date__c + ReadonlyConsole_Account_ID__c + + + + + false + false + true + + + + Required + Name + + + Required + AccountId + + + Edit + Title + + + Edit + Email + + + Edit + Phone + + + Edit + MobilePhone + + + + + Edit + OwnerId + + + Edit + ReportsToId + + + Edit + Department + + + Edit + Fax + + + + + + false + true + true + + + + Edit + MailingAddress + + + + + + + false + true + true + + + + + + + false + false + true + + + + + + false + true + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + false + false + false + + + + GoogleMaps + + + + + GoogleSearch + + + + + + + Record + + Edit + StandardButton + 0 + + + Delete + StandardButton + 1 + + + Clone + StandardButton + 2 + + + SendEmail + QuickAction + 3 + + + LogACall + QuickAction + 4 + + + NewTask + QuickAction + 5 + + + NewEvent + QuickAction + 6 + + + EmailHighlightAction + ProductivityAction + 7 + + + FeedItem.ContentNote + QuickAction + 8 + + + FeedItem.TextPost + QuickAction + 9 + + + FeedItem.ContentPost + QuickAction + 10 + + + + + FeedItem.TextPost + + + FeedItem.ContentPost + + + LogACall + + + NewTask + + + NewEvent + + + FeedItem.ContentNote + + + FeedItem.LinkPost + + + FeedItem.PollPost + + + SendEmail + + + + OPPORTUNITY.NAME + OPPORTUNITY.STAGE_NAME + OPPORTUNITY.AMOUNT + OPPORTUNITY.CLOSE_DATE + RelatedOpportunityList + + + QUOTE.QUOTENUMBER + QUOTE.NAME + QUOTE.EXPIRATIONDATE + QUOTE.ISSYNCING + QUOTE.SUBTOTAL + QUOTE.TOTALPRICE + CreatedBy + RelatedQuoteList + + + CASES.CASE_NUMBER + CASES.STATUS + CASES.CREATED_DATE_DATE_ONLY + CASES.SUBJECT + RelatedCaseList + + + CAMPAIGN.NAME + CAMPAIGN.START_DATE + CAMPAIGN.CAMPAIGN_TYPE + CM.STATUS + CM.RESPONDED + CM.LAST_UPDATE + RelatedCampaignList + + + RelatedFileList + + + RelatedContentNoteList + + + TASK.SUBJECT + TASK.WHAT_NAME + ACTIVITY.TASK + TASK.DUE_DATE + TASK.STATUS + CORE.USERS.FULL_NAME + EVENT.LOCATION + RelatedActivityList + + + TASK.SUBJECT + TASK.WHAT_NAME + ACTIVITY.TASK + TASK.DUE_DATE + EVENT.LOCATION + CORE.USERS.FULL_NAME + RelatedHistoryList + + AccountId + false + false + false + false + false + + 00hHn00000diPWw + 4 + 0 + Default + + diff --git a/salesforce/force-app/main/default/layouts/Lead-Lead Layout.layout-meta.xml b/salesforce/force-app/main/default/layouts/Lead-Lead Layout.layout-meta.xml new file mode 100644 index 000000000000..0fd0d260632f --- /dev/null +++ b/salesforce/force-app/main/default/layouts/Lead-Lead Layout.layout-meta.xml @@ -0,0 +1,293 @@ + + + Submit + + truetruetrue + + + ReadonlyHas_Console_Account__c + ReadonlyConsole_Signup_Date__c + ReadonlyConsole_Account_ID__c + + + + + false + false + false + + + + Required + Status + + + Required + Name + + + Edit + Title + + + Edit + Email + + + Edit + Phone + + + Edit + MobilePhone + + + Edit + Rating + + + + + Edit + OwnerId + + + Edit + Website + + + Required + Company + + + Edit + Industry + + + Edit + NumberOfEmployees + + + Edit + LeadSource + + + Edit + Current_Monthly_Inference_Spend__c + + + Edit + Description + + + + + + false + true + true + + + + Edit + Address + + + + + + + false + true + true + + + + + + + false + false + true + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + false + true + false + + + + GoogleMaps + + + + + + + + Record + + Edit + StandardButton + 0 + + + Delete + StandardButton + 1 + + + Clone + StandardButton + 2 + + + Convert + StandardButton + 3 + + + FeedItem.ContentNote + QuickAction + 4 + + + LogACall + QuickAction + 5 + + + NewTask + QuickAction + 6 + + + NewEvent + QuickAction + 7 + + + FeedItem.TextPost + QuickAction + 8 + + + FeedItem.ContentPost + QuickAction + 9 + + + SendEmail + QuickAction + 10 + + + EmailHighlightAction + ProductivityAction + 11 + + + + + FeedItem.TextPost + + + FeedItem.ContentPost + + + LogACall + + + NewTask + + + NewEvent + + + FeedItem.LinkPost + + + FeedItem.PollPost + + + FeedItem.ContentNote + + + SendEmail + + + + + + Readonly + CampaignId + + + + + CAMPAIGN.NAME + CAMPAIGN.START_DATE + CAMPAIGN.CAMPAIGN_TYPE + CM.STATUS + CM.RESPONDED + CM.LAST_UPDATE + RelatedCampaignList + + + RelatedFileList + + + RelatedContentNoteList + + + TASK.SUBJECT + ACTIVITY.TASK + TASK.DUE_DATE + TASK.STATUS + CORE.USERS.FULL_NAME + EVENT.LOCATION + RelatedActivityList + + + TASK.SUBJECT + ACTIVITY.TASK + TASK.DUE_DATE + EVENT.LOCATION + CORE.USERS.FULL_NAME + RelatedHistoryList + + false + false + false + false + true + false + + 00ha500000BShn4 + 4 + 0 + Default + + diff --git a/salesforce/force-app/main/default/objects/Contact/fields/Console_Account_ID__c.field-meta.xml b/salesforce/force-app/main/default/objects/Contact/fields/Console_Account_ID__c.field-meta.xml new file mode 100644 index 000000000000..ab7df72c04d2 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Contact/fields/Console_Account_ID__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Console_Account_ID__c + The stable OpenCode Console user ID. Identifies the person, not their workspace. + true + + 255 + false + Text + false + diff --git a/salesforce/force-app/main/default/objects/Contact/fields/Console_Signup_Date__c.field-meta.xml b/salesforce/force-app/main/default/objects/Contact/fields/Console_Signup_Date__c.field-meta.xml new file mode 100644 index 000000000000..2d36eb8f1872 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Contact/fields/Console_Signup_Date__c.field-meta.xml @@ -0,0 +1,8 @@ + + + Console_Signup_Date__c + Creation time of the verified Console account, in UTC. + + false + DateTime + diff --git a/salesforce/force-app/main/default/objects/Contact/fields/Has_Console_Account__c.field-meta.xml b/salesforce/force-app/main/default/objects/Contact/fields/Has_Console_Account__c.field-meta.xml new file mode 100644 index 000000000000..acbb17b3f916 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Contact/fields/Has_Console_Account__c.field-meta.xml @@ -0,0 +1,8 @@ + + + Has_Console_Account__c + false + True when this person has a verified OpenCode Console account. Existing records are not backfilled. + + Checkbox + diff --git a/salesforce/force-app/main/default/objects/Lead/fields/Console_Account_ID__c.field-meta.xml b/salesforce/force-app/main/default/objects/Lead/fields/Console_Account_ID__c.field-meta.xml new file mode 100644 index 000000000000..ab7df72c04d2 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Lead/fields/Console_Account_ID__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Console_Account_ID__c + The stable OpenCode Console user ID. Identifies the person, not their workspace. + true + + 255 + false + Text + false + diff --git a/salesforce/force-app/main/default/objects/Lead/fields/Console_Signup_Date__c.field-meta.xml b/salesforce/force-app/main/default/objects/Lead/fields/Console_Signup_Date__c.field-meta.xml new file mode 100644 index 000000000000..2d36eb8f1872 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Lead/fields/Console_Signup_Date__c.field-meta.xml @@ -0,0 +1,8 @@ + + + Console_Signup_Date__c + Creation time of the verified Console account, in UTC. + + false + DateTime + diff --git a/salesforce/force-app/main/default/objects/Lead/fields/Has_Console_Account__c.field-meta.xml b/salesforce/force-app/main/default/objects/Lead/fields/Has_Console_Account__c.field-meta.xml new file mode 100644 index 000000000000..acbb17b3f916 --- /dev/null +++ b/salesforce/force-app/main/default/objects/Lead/fields/Has_Console_Account__c.field-meta.xml @@ -0,0 +1,8 @@ + + + Has_Console_Account__c + false + True when this person has a verified OpenCode Console account. Existing records are not backfilled. + + Checkbox + diff --git a/salesforce/force-app/main/default/permissionsets/Console_Account_Sync.permissionset-meta.xml b/salesforce/force-app/main/default/permissionsets/Console_Account_Sync.permissionset-meta.xml new file mode 100644 index 000000000000..d61b720534d9 --- /dev/null +++ b/salesforce/force-app/main/default/permissionsets/Console_Account_Sync.permissionset-meta.xml @@ -0,0 +1,11 @@ + + + Read and update Console account identity on Leads and Contacts. + trueLead.Has_Console_Account__ctrue + trueLead.Console_Account_ID__ctrue + trueLead.Console_Signup_Date__ctrue + trueContact.Has_Console_Account__ctrue + trueContact.Console_Account_ID__ctrue + trueContact.Console_Signup_Date__ctrue + + diff --git a/salesforce/force-app/main/default/standardValueSets/LeadSource.standardValueSet-meta.xml b/salesforce/force-app/main/default/standardValueSets/LeadSource.standardValueSet-meta.xml new file mode 100644 index 000000000000..fd766e0c6e02 --- /dev/null +++ b/salesforce/force-app/main/default/standardValueSets/LeadSource.standardValueSet-meta.xml @@ -0,0 +1,69 @@ + + + false + + Advertisement + false + + + + Customer Event + false + + + + Employee Referral + false + + + + External Referral + false + + + + Google AdWords + false + + + + Other + false + + + + Partner + false + + + + Purchased List + false + + + + Trade Show + false + + + + Webinar + false + + + + Website + false + + + + enterprise website form + false + + + + console signup + false + + + diff --git a/salesforce/sfdx-project.json b/salesforce/sfdx-project.json new file mode 100644 index 000000000000..3b629a98b576 --- /dev/null +++ b/salesforce/sfdx-project.json @@ -0,0 +1,6 @@ +{ + "packageDirectories": [{ "path": "force-app", "default": true }], + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "64.0" +}