Skip to content

Commit 07c0ff5

Browse files
authored
feat(console): enrich Salesforce leads with Console account status (#54387)
1 parent fe1717b commit 07c0ff5

24 files changed

Lines changed: 1460 additions & 37 deletions

‎infra/console.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
2828
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
2929
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
3030
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
31+
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
32+
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
33+
properties: { value: `https://${domain}/console/api/internal/console-account` },
34+
})
3135

3236
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
3337
handler: "packages/console/function/src/log-processor.ts",
@@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", {
4852
SALESFORCE_CLIENT_ID,
4953
SALESFORCE_CLIENT_SECRET,
5054
SALESFORCE_INSTANCE_URL,
55+
CONSOLE_CRM_TOKEN,
56+
CONSOLE_CRM_LOOKUP_URL,
5157
],
5258
environment: {
5359
//VITE_DOCS_URL: web.url.apply((url) => url!),

‎packages/console/app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"typecheck": "tsgo --noEmit",
88
"dev": "vite dev --host 0.0.0.0",
9+
"dev:enterprise": "bun --watch script/dev-enterprise.ts",
910
"dev:remote": "bun sst shell --stage=dev bun dev",
1011
"build": "bun ./script/generate-sitemap.ts && vite build",
1112
"start": "vite start"
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { handleEnterpriseRequest } from "../src/routes/api/enterprise"
2+
import { z } from "zod"
3+
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
4+
import { handleConsoleSignup } from "../src/routes/api/console-signup"
5+
import { consoleAccountSchema } from "../src/lib/console-account"
6+
7+
const org = process.env.SF_TARGET_ORG || "opencode"
8+
const crmToken = process.env.CONSOLE_CRM_TOKEN
9+
if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token")
10+
const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account"
11+
12+
async function authenticatedRequest(
13+
path: string,
14+
payload?: Record<string, unknown>,
15+
method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET",
16+
) {
17+
const child = Bun.spawn(
18+
[
19+
"sf",
20+
"api",
21+
"request",
22+
"rest",
23+
path,
24+
"--target-org",
25+
org,
26+
"--json",
27+
"--method",
28+
method,
29+
...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []),
30+
],
31+
{
32+
stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore",
33+
stdout: "pipe",
34+
stderr: "pipe",
35+
},
36+
)
37+
const [output] = await Promise.all([
38+
new Response(child.stdout).text(),
39+
child.exited,
40+
new Response(child.stderr).text(),
41+
])
42+
return salesforceCliResponse(output)
43+
}
44+
45+
await authenticatedRequest("/services/data/v59.0/")
46+
47+
const server = Bun.serve({
48+
hostname: "127.0.0.1",
49+
port: Number(process.env.ENTERPRISE_DEV_PORT || 3101),
50+
async fetch(request) {
51+
const path = new URL(request.url).pathname
52+
if (path === "/health" && request.method === "GET") {
53+
return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org })
54+
}
55+
if (path === "/api/console-signup" && request.method === "POST") {
56+
return handleConsoleSignup(request, crmToken, (path, method, payload) =>
57+
authenticatedRequest(path, payload, method),
58+
)
59+
}
60+
if (path !== "/api/enterprise") return new Response("Not found", { status: 404 })
61+
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 })
62+
const failures: string[] = []
63+
const response = await handleEnterpriseRequest(request, {
64+
salesforceSubmit: async (payload) => {
65+
const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch(
66+
(error: unknown) => {
67+
const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR"
68+
failures.push(code)
69+
console.error(`Local Salesforce submission rejected: ${code}`)
70+
return undefined
71+
},
72+
)
73+
return z.object({ success: z.literal(true) }).safeParse(result).success
74+
},
75+
notifications: false,
76+
accountLookup: async (email) => {
77+
const response = await fetch(accountUrl, {
78+
method: "POST",
79+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` },
80+
body: JSON.stringify({ email }),
81+
signal: AbortSignal.timeout(8000),
82+
})
83+
if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`)
84+
return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account
85+
},
86+
})
87+
if (failures.length > 0) {
88+
return Response.json(
89+
{ error: "Salesforce rejected the request.", errorCode: failures[0] },
90+
{ status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } },
91+
)
92+
}
93+
response.headers.set("X-Enterprise-Handler", "local-sst-enterprise")
94+
return response
95+
},
96+
})
97+
98+
console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`)
99+
console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from "bun:test"
2+
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
3+
4+
test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => {
5+
expect(
6+
salesforceCliResponse(
7+
JSON.stringify({
8+
status: 0,
9+
result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } },
10+
}),
11+
),
12+
).toEqual({ id: "test-lead", success: true, errors: [] })
13+
})
14+
15+
test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => {
16+
expect(() =>
17+
salesforceCliResponse(
18+
JSON.stringify({
19+
status: 0,
20+
result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] },
21+
}),
22+
),
23+
).toThrow()
24+
})
25+
26+
test("rejects CLI errors and responses missing the HTTP envelope", () => {
27+
expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow()
28+
expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow()
29+
})
30+
31+
test("preserves the Salesforce rejection code when the CLI exits nonzero", () => {
32+
expect(() =>
33+
salesforceCliResponse(
34+
JSON.stringify({
35+
status: 1,
36+
result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] },
37+
}),
38+
),
39+
).toThrow(new SalesforceCliError("DUPLICATES_DETECTED"))
40+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { z } from "zod"
2+
3+
export class SalesforceCliError extends Error {
4+
constructor(readonly code: string) {
5+
super(`Salesforce request failed: ${code}`)
6+
}
7+
}
8+
9+
export function salesforceCliResponse(output: string) {
10+
const response = z
11+
.object({
12+
status: z.number(),
13+
result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(),
14+
})
15+
.parse(JSON.parse(output))
16+
if (!response.result) throw new SalesforceCliError("CLI_ERROR")
17+
if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) {
18+
const errors = z
19+
.array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) }))
20+
.safeParse(response.result.body)
21+
throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED")
22+
}
23+
return response.result.body
24+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Resource } from "@opencode/console-resource"
2+
import { z } from "zod"
3+
4+
export const consoleAccountSchema = z.object({
5+
id: z.string().min(1).max(255),
6+
email: z.email(),
7+
name: z.string().nullable(),
8+
createdAt: z.iso.datetime(),
9+
})
10+
export type ConsoleAccount = z.infer<typeof consoleAccountSchema>
11+
12+
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
13+
const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, {
14+
method: "POST",
15+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` },
16+
body: JSON.stringify({ email }),
17+
signal: AbortSignal.timeout(3000),
18+
})
19+
if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`)
20+
const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json())
21+
return result.account
22+
}
23+
24+
export function consoleAccountFields(account: ConsoleAccount | null) {
25+
if (!account) return { Has_Console_Account__c: false }
26+
return {
27+
Has_Console_Account__c: true,
28+
Console_Account_ID__c: account.id,
29+
Console_Signup_Date__c: account.createdAt,
30+
}
31+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { afterEach, expect, test } from "bun:test"
2+
import { syncConsoleAccount, type SalesforceRequest } from "./salesforce"
3+
import { handleConsoleSignup } from "../routes/api/console-signup"
4+
import { z } from "zod"
5+
6+
const account = {
7+
id: "user_console_test",
8+
email: "person@example.test",
9+
name: "Test Person",
10+
createdAt: "2026-10-01T10:00:00.000Z",
11+
}
12+
const servers = new Set<ReturnType<typeof Bun.serve>>()
13+
afterEach(async () => {
14+
await Promise.all([...servers].map((server) => server.stop(true)))
15+
servers.clear()
16+
})
17+
18+
function fixture(
19+
initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [],
20+
email = account.email,
21+
) {
22+
const people: Record<string, unknown>[] = initial.map((person) => ({ ...person }))
23+
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
24+
const server = Bun.serve({
25+
hostname: "127.0.0.1",
26+
port: 0,
27+
async fetch(request) {
28+
const url = new URL(request.url)
29+
if (url.pathname.endsWith("/query")) {
30+
const query = url.searchParams.get("q") ?? ""
31+
expect(query).toContain("Console_Account_ID__c")
32+
expect(query).toContain(`Email = '${email.toLowerCase()}'`)
33+
const object = query.includes("FROM Contact") ? "Contact" : "Lead"
34+
if (object === "Lead") expect(query).toContain("IsConverted = false")
35+
return Response.json({
36+
done: true,
37+
records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })),
38+
})
39+
}
40+
const payload = z.record(z.string(), z.unknown()).parse(await request.json())
41+
writes.push({ method: request.method, path: url.pathname, payload })
42+
if (request.method === "PATCH") {
43+
const person = people.find((person) => person.Id === url.pathname.split("/").at(-1))
44+
if (!person) return Response.json({ error: "Not found" }, { status: 404 })
45+
Object.assign(person, payload)
46+
return new Response(null, { status: 204 })
47+
}
48+
const id = "00Q000000000001AAA"
49+
people.push({ object: "Lead", Id: id, ...payload })
50+
return Response.json({ success: true, id }, { status: 201 })
51+
},
52+
})
53+
servers.add(server)
54+
const request: SalesforceRequest = async (path, method = "GET", payload) => {
55+
const response = await fetch(new URL(path, server.url), {
56+
method,
57+
...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}),
58+
})
59+
if (!response.ok) throw new Error("CRM request failed")
60+
return response.status === 204 ? null : response.json()
61+
}
62+
return { people, writes, request }
63+
}
64+
65+
test("a new signup creates one Console lead and an exact retry updates it", async () => {
66+
const crm = fixture()
67+
await syncConsoleAccount(account, crm.request)
68+
await syncConsoleAccount(account, crm.request)
69+
expect(crm.people).toHaveLength(1)
70+
expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1)
71+
expect(crm.people[0]).toMatchObject({
72+
Company: "example.test",
73+
LeadSource: "console signup",
74+
Has_Console_Account__c: true,
75+
Console_Account_ID__c: account.id,
76+
Console_Signup_Date__c: account.createdAt,
77+
})
78+
expect(crm.writes[1].payload).not.toHaveProperty("LeadSource")
79+
expect(crm.writes[1].payload).not.toHaveProperty("Company")
80+
})
81+
82+
test.each([
83+
["hello@acme.com", "acme.com"],
84+
["jane@company.co.uk", "company.co.uk"],
85+
["HELLO@ACME.COM", "acme.com"],
86+
["person@gmail.com", "Individual"],
87+
["person+signup@GOOGLEMAIL.COM", "Individual"],
88+
["person@outlook.com", "Individual"],
89+
["person@hotmail.co.uk", "Individual"],
90+
["person@yahoo.com", "Individual"],
91+
["person@icloud.com", "Individual"],
92+
["person@proton.me", "Individual"],
93+
["person@protonmail.com", "Individual"],
94+
["person@pm.me", "Individual"],
95+
["person@aol.com", "Individual"],
96+
["person@gmail.com.example.test", "gmail.com.example.test"],
97+
])("sets Company for a new signup with %s to %s", async (email, company) => {
98+
const crm = fixture([], email)
99+
await syncConsoleAccount({ ...account, email }, crm.request)
100+
expect(crm.people).toHaveLength(1)
101+
expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" })
102+
})
103+
104+
test("matching Leads and Contacts keep their source and receive the Console fields", async () => {
105+
const crm = fixture([
106+
{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" },
107+
{ object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" },
108+
])
109+
await syncConsoleAccount(account, crm.request)
110+
expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true)
111+
expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"])
112+
expect(crm.people[0].AccountId).toBe("001000000000001AAA")
113+
expect(crm.people[1].Company).toBe("Visitor's Company")
114+
for (const write of crm.writes) {
115+
expect(write.payload).not.toHaveProperty("Company")
116+
expect(write.payload).not.toHaveProperty("AccountId")
117+
}
118+
for (const person of crm.people)
119+
expect(person).toMatchObject({
120+
Has_Console_Account__c: true,
121+
Console_Account_ID__c: account.id,
122+
Console_Signup_Date__c: account.createdAt,
123+
})
124+
})
125+
126+
test("a converted person's Contact is updated without creating another Lead", async () => {
127+
const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }])
128+
await syncConsoleAccount(account, crm.request)
129+
expect(crm.people).toHaveLength(1)
130+
expect(crm.writes[0].path).toContain("/Contact/")
131+
expect(crm.people[0].LeadSource).toBe("Website")
132+
})
133+
134+
test("the signup endpoint requires its server credential before touching Salesforce", async () => {
135+
const crm = fixture()
136+
const response = await handleConsoleSignup(
137+
new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }),
138+
"test-shared-token",
139+
crm.request,
140+
)
141+
expect(response.status).toBe(401)
142+
expect(crm.writes).toHaveLength(0)
143+
})
144+
145+
test("the authenticated signup endpoint reports confirmed delivery", async () => {
146+
const crm = fixture()
147+
const response = await handleConsoleSignup(
148+
new Request("http://localhost/api/console-signup", {
149+
method: "POST",
150+
headers: { Authorization: "Bearer test-shared-token" },
151+
body: JSON.stringify(account),
152+
}),
153+
"test-shared-token",
154+
crm.request,
155+
)
156+
expect(response.status).toBe(200)
157+
expect(await response.json()).toMatchObject({ success: true })
158+
expect(crm.people).toHaveLength(1)
159+
})

0 commit comments

Comments
 (0)