feat(console): enrich Salesforce leads with Console account status (#54387)

This commit is contained in:
Stefan Avram 2026-10-14 12:28:23 -04:00 • committed by GitHub
parent fe1717b309
commit 07c0ff5914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1460 additions and 37 deletions

View file

@ -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!),

View file

@ -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"

View file

@ -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<string, unknown>,
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.`)

View file

@ -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"))
})

View file

@ -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
}

View file

@ -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<typeof consoleAccountSchema>
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
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,
}
}

View file

@ -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<ReturnType<typeof Bun.serve>>()
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<string, unknown>[] = initial.map((person) => ({ ...person }))
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
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)
})

View file

@ -0,0 +1,112 @@
import { afterAll, expect, test } from "bun:test"
const leads: Record<string, unknown>[] = []
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",
})
})

View file

@ -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<boolean> {
export async function createLead(
input: SalesforceLeadInput,
submit?: (payload: Record<string, unknown>) => Promise<boolean>,
): Promise<boolean> {
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<boolean> {
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<boolean> {
return true
}
export type SalesforceRequest = (
path: string,
method?: "GET" | "POST" | "PATCH",
payload?: Record<string, unknown>,
) => Promise<unknown>
export async function salesforceRequest(): Promise<SalesforceRequest> {
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 }
}

View file

@ -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 }),
)
}

View file

@ -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<string, unknown>) => Promise<boolean>
notifications?: boolean
accountLookup?: (email: string) => Promise<ConsoleAccount | null>
} = {},
) {
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}<br><br>
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
@ -94,31 +111,37 @@ ${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.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) {

38
salesforce/README.md Normal file
View file

@ -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.

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<LeadConvertSettings xmlns="http://soap.sforce.com/2006/04/metadata">
<allowOwnerChange>true</allowOwnerChange>
<objectMapping>
<inputObject>Lead</inputObject>
<mappingFields>
<inputField>Has_Console_Account__c</inputField>
<outputField>Has_Console_Account__c</outputField>
</mappingFields>
<mappingFields>
<inputField>Console_Account_ID__c</inputField>
<outputField>Console_Account_ID__c</outputField>
</mappingFields>
<mappingFields>
<inputField>Console_Signup_Date__c</inputField>
<outputField>Console_Signup_Date__c</outputField>
</mappingFields>
<outputObject>Contact</outputObject>
</objectMapping>
<opportunityCreationOptions>VisibleOptional</opportunityCreationOptions>
</LeadConvertSettings>

View file

@ -0,0 +1,294 @@
<?xml version="1.0" encoding="UTF-8"?>
<Layout xmlns="http://soap.sforce.com/2006/04/metadata">
<excludeButtons>DisableCustomerPortal</excludeButtons>
<excludeButtons>DisablePartnerPortal</excludeButtons>
<excludeButtons>DisablePrmSuperUser</excludeButtons>
<excludeButtons>EnablePrmSuperUser</excludeButtons>
<excludeButtons>Share</excludeButtons>
<layoutSections>
<customLabel>true</customLabel><detailHeading>true</detailHeading><editHeading>true</editHeading>
<label>Console Account</label>
<layoutColumns>
<layoutItems><behavior>Readonly</behavior><field>Has_Console_Account__c</field></layoutItems>
<layoutItems><behavior>Readonly</behavior><field>Console_Signup_Date__c</field></layoutItems>
<layoutItems><behavior>Readonly</behavior><field>Console_Account_ID__c</field></layoutItems>
</layoutColumns>
<style>OneColumn</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>Contact Information</label>
<layoutColumns>
<layoutItems>
<behavior>Required</behavior>
<field>Name</field>
</layoutItems>
<layoutItems>
<behavior>Required</behavior>
<field>AccountId</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Title</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Email</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Phone</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>MobilePhone</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Edit</behavior>
<field>OwnerId</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>ReportsToId</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Department</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Fax</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>true</editHeading>
<label>Address Information</label>
<layoutColumns>
<layoutItems>
<behavior>Edit</behavior>
<field>MailingAddress</field>
</layoutItems>
</layoutColumns>
<layoutColumns/>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>true</editHeading>
<label>Additional Information</label>
<layoutColumns/>
<layoutColumns/>
<style>TwoColumnsLeftToRight</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>Description Information</label>
<layoutColumns/>
<style>OneColumn</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>true</editHeading>
<label>System Information</label>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>CreatedById</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>LastModifiedById</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>false</editHeading>
<label>Custom Links</label>
<layoutColumns>
<layoutItems>
<customLink>GoogleMaps</customLink>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<customLink>GoogleSearch</customLink>
</layoutItems>
</layoutColumns>
<layoutColumns/>
<style>CustomLinks</style>
</layoutSections>
<platformActionList>
<actionListContext>Record</actionListContext>
<platformActionListItems>
<actionName>Edit</actionName>
<actionType>StandardButton</actionType>
<sortOrder>0</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>Delete</actionName>
<actionType>StandardButton</actionType>
<sortOrder>1</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>Clone</actionName>
<actionType>StandardButton</actionType>
<sortOrder>2</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>SendEmail</actionName>
<actionType>QuickAction</actionType>
<sortOrder>3</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>LogACall</actionName>
<actionType>QuickAction</actionType>
<sortOrder>4</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>NewTask</actionName>
<actionType>QuickAction</actionType>
<sortOrder>5</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>NewEvent</actionName>
<actionType>QuickAction</actionType>
<sortOrder>6</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>EmailHighlightAction</actionName>
<actionType>ProductivityAction</actionType>
<sortOrder>7</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.ContentNote</actionName>
<actionType>QuickAction</actionType>
<sortOrder>8</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.TextPost</actionName>
<actionType>QuickAction</actionType>
<sortOrder>9</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.ContentPost</actionName>
<actionType>QuickAction</actionType>
<sortOrder>10</sortOrder>
</platformActionListItems>
</platformActionList>
<quickActionList>
<quickActionListItems>
<quickActionName>FeedItem.TextPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.ContentPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>LogACall</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>NewTask</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>NewEvent</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.ContentNote</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.LinkPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.PollPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>SendEmail</quickActionName>
</quickActionListItems>
</quickActionList>
<relatedLists>
<fields>OPPORTUNITY.NAME</fields>
<fields>OPPORTUNITY.STAGE_NAME</fields>
<fields>OPPORTUNITY.AMOUNT</fields>
<fields>OPPORTUNITY.CLOSE_DATE</fields>
<relatedList>RelatedOpportunityList</relatedList>
</relatedLists>
<relatedLists>
<fields>QUOTE.QUOTENUMBER</fields>
<fields>QUOTE.NAME</fields>
<fields>QUOTE.EXPIRATIONDATE</fields>
<fields>QUOTE.ISSYNCING</fields>
<fields>QUOTE.SUBTOTAL</fields>
<fields>QUOTE.TOTALPRICE</fields>
<fields>CreatedBy</fields>
<relatedList>RelatedQuoteList</relatedList>
</relatedLists>
<relatedLists>
<fields>CASES.CASE_NUMBER</fields>
<fields>CASES.STATUS</fields>
<fields>CASES.CREATED_DATE_DATE_ONLY</fields>
<fields>CASES.SUBJECT</fields>
<relatedList>RelatedCaseList</relatedList>
</relatedLists>
<relatedLists>
<fields>CAMPAIGN.NAME</fields>
<fields>CAMPAIGN.START_DATE</fields>
<fields>CAMPAIGN.CAMPAIGN_TYPE</fields>
<fields>CM.STATUS</fields>
<fields>CM.RESPONDED</fields>
<fields>CM.LAST_UPDATE</fields>
<relatedList>RelatedCampaignList</relatedList>
</relatedLists>
<relatedLists>
<relatedList>RelatedFileList</relatedList>
</relatedLists>
<relatedLists>
<relatedList>RelatedContentNoteList</relatedList>
</relatedLists>
<relatedLists>
<fields>TASK.SUBJECT</fields>
<fields>TASK.WHAT_NAME</fields>
<fields>ACTIVITY.TASK</fields>
<fields>TASK.DUE_DATE</fields>
<fields>TASK.STATUS</fields>
<fields>CORE.USERS.FULL_NAME</fields>
<fields>EVENT.LOCATION</fields>
<relatedList>RelatedActivityList</relatedList>
</relatedLists>
<relatedLists>
<fields>TASK.SUBJECT</fields>
<fields>TASK.WHAT_NAME</fields>
<fields>ACTIVITY.TASK</fields>
<fields>TASK.DUE_DATE</fields>
<fields>EVENT.LOCATION</fields>
<fields>CORE.USERS.FULL_NAME</fields>
<relatedList>RelatedHistoryList</relatedList>
</relatedLists>
<relatedObjects>AccountId</relatedObjects>
<showEmailCheckbox>false</showEmailCheckbox>
<showHighlightsPanel>false</showHighlightsPanel>
<showInteractionLogPanel>false</showInteractionLogPanel>
<showRunAssignmentRulesCheckbox>false</showRunAssignmentRulesCheckbox>
<showSubmitAndAttachButton>false</showSubmitAndAttachButton>
<summaryLayout>
<masterLabel>00hHn00000diPWw</masterLabel>
<sizeX>4</sizeX>
<sizeY>0</sizeY>
<summaryLayoutStyle>Default</summaryLayoutStyle>
</summaryLayout>
</Layout>

View file

@ -0,0 +1,293 @@
<?xml version="1.0" encoding="UTF-8"?>
<Layout xmlns="http://soap.sforce.com/2006/04/metadata">
<excludeButtons>Submit</excludeButtons>
<layoutSections>
<customLabel>true</customLabel><detailHeading>true</detailHeading><editHeading>true</editHeading>
<label>Console Account</label>
<layoutColumns>
<layoutItems><behavior>Readonly</behavior><field>Has_Console_Account__c</field></layoutItems>
<layoutItems><behavior>Readonly</behavior><field>Console_Signup_Date__c</field></layoutItems>
<layoutItems><behavior>Readonly</behavior><field>Console_Account_ID__c</field></layoutItems>
</layoutColumns>
<style>OneColumn</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>false</editHeading>
<label>Lead Information</label>
<layoutColumns>
<layoutItems>
<behavior>Required</behavior>
<field>Status</field>
</layoutItems>
<layoutItems>
<behavior>Required</behavior>
<field>Name</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Title</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Email</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Phone</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>MobilePhone</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Rating</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Edit</behavior>
<field>OwnerId</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Website</field>
</layoutItems>
<layoutItems>
<behavior>Required</behavior>
<field>Company</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Industry</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>NumberOfEmployees</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>LeadSource</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Current_Monthly_Inference_Spend__c</field>
</layoutItems>
<layoutItems>
<behavior>Edit</behavior>
<field>Description</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>true</editHeading>
<label>Address Information</label>
<layoutColumns>
<layoutItems>
<behavior>Edit</behavior>
<field>Address</field>
</layoutItems>
</layoutColumns>
<layoutColumns/>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>true</editHeading>
<label>Additional Information</label>
<layoutColumns/>
<layoutColumns/>
<style>TwoColumnsLeftToRight</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>Description Information</label>
<layoutColumns/>
<style>OneColumn</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>System Information</label>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>CreatedById</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>LastModifiedById</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>true</detailHeading>
<editHeading>false</editHeading>
<label>Custom Links</label>
<layoutColumns>
<layoutItems>
<customLink>GoogleMaps</customLink>
</layoutItems>
</layoutColumns>
<layoutColumns/>
<layoutColumns/>
<style>CustomLinks</style>
</layoutSections>
<platformActionList>
<actionListContext>Record</actionListContext>
<platformActionListItems>
<actionName>Edit</actionName>
<actionType>StandardButton</actionType>
<sortOrder>0</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>Delete</actionName>
<actionType>StandardButton</actionType>
<sortOrder>1</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>Clone</actionName>
<actionType>StandardButton</actionType>
<sortOrder>2</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>Convert</actionName>
<actionType>StandardButton</actionType>
<sortOrder>3</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.ContentNote</actionName>
<actionType>QuickAction</actionType>
<sortOrder>4</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>LogACall</actionName>
<actionType>QuickAction</actionType>
<sortOrder>5</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>NewTask</actionName>
<actionType>QuickAction</actionType>
<sortOrder>6</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>NewEvent</actionName>
<actionType>QuickAction</actionType>
<sortOrder>7</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.TextPost</actionName>
<actionType>QuickAction</actionType>
<sortOrder>8</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>FeedItem.ContentPost</actionName>
<actionType>QuickAction</actionType>
<sortOrder>9</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>SendEmail</actionName>
<actionType>QuickAction</actionType>
<sortOrder>10</sortOrder>
</platformActionListItems>
<platformActionListItems>
<actionName>EmailHighlightAction</actionName>
<actionType>ProductivityAction</actionType>
<sortOrder>11</sortOrder>
</platformActionListItems>
</platformActionList>
<quickActionList>
<quickActionListItems>
<quickActionName>FeedItem.TextPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.ContentPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>LogACall</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>NewTask</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>NewEvent</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.LinkPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.PollPost</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>FeedItem.ContentNote</quickActionName>
</quickActionListItems>
<quickActionListItems>
<quickActionName>SendEmail</quickActionName>
</quickActionListItems>
</quickActionList>
<relatedContent>
<relatedContentItems>
<layoutItem>
<behavior>Readonly</behavior>
<field>CampaignId</field>
</layoutItem>
</relatedContentItems>
</relatedContent>
<relatedLists>
<fields>CAMPAIGN.NAME</fields>
<fields>CAMPAIGN.START_DATE</fields>
<fields>CAMPAIGN.CAMPAIGN_TYPE</fields>
<fields>CM.STATUS</fields>
<fields>CM.RESPONDED</fields>
<fields>CM.LAST_UPDATE</fields>
<relatedList>RelatedCampaignList</relatedList>
</relatedLists>
<relatedLists>
<relatedList>RelatedFileList</relatedList>
</relatedLists>
<relatedLists>
<relatedList>RelatedContentNoteList</relatedList>
</relatedLists>
<relatedLists>
<fields>TASK.SUBJECT</fields>
<fields>ACTIVITY.TASK</fields>
<fields>TASK.DUE_DATE</fields>
<fields>TASK.STATUS</fields>
<fields>CORE.USERS.FULL_NAME</fields>
<fields>EVENT.LOCATION</fields>
<relatedList>RelatedActivityList</relatedList>
</relatedLists>
<relatedLists>
<fields>TASK.SUBJECT</fields>
<fields>ACTIVITY.TASK</fields>
<fields>TASK.DUE_DATE</fields>
<fields>EVENT.LOCATION</fields>
<fields>CORE.USERS.FULL_NAME</fields>
<relatedList>RelatedHistoryList</relatedList>
</relatedLists>
<runAssignmentRulesDefault>false</runAssignmentRulesDefault>
<showEmailCheckbox>false</showEmailCheckbox>
<showHighlightsPanel>false</showHighlightsPanel>
<showInteractionLogPanel>false</showInteractionLogPanel>
<showRunAssignmentRulesCheckbox>true</showRunAssignmentRulesCheckbox>
<showSubmitAndAttachButton>false</showSubmitAndAttachButton>
<summaryLayout>
<masterLabel>00ha500000BShn4</masterLabel>
<sizeX>4</sizeX>
<sizeY>0</sizeY>
<summaryLayoutStyle>Default</summaryLayoutStyle>
</summaryLayout>
</Layout>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Console_Account_ID__c</fullName>
<description>The stable OpenCode Console user ID. Identifies the person, not their workspace.</description>
<externalId>true</externalId>
<label>Console Account ID</label>
<length>255</length>
<required>false</required>
<type>Text</type>
<unique>false</unique>
</CustomField>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Console_Signup_Date__c</fullName>
<description>Creation time of the verified Console account, in UTC.</description>
<label>Console Signup Date</label>
<required>false</required>
<type>DateTime</type>
</CustomField>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Has_Console_Account__c</fullName>
<defaultValue>false</defaultValue>
<description>True when this person has a verified OpenCode Console account. Existing records are not backfilled.</description>
<label>Has Console Account</label>
<type>Checkbox</type>
</CustomField>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Console_Account_ID__c</fullName>
<description>The stable OpenCode Console user ID. Identifies the person, not their workspace.</description>
<externalId>true</externalId>
<label>Console Account ID</label>
<length>255</length>
<required>false</required>
<type>Text</type>
<unique>false</unique>
</CustomField>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Console_Signup_Date__c</fullName>
<description>Creation time of the verified Console account, in UTC.</description>
<label>Console Signup Date</label>
<required>false</required>
<type>DateTime</type>
</CustomField>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Has_Console_Account__c</fullName>
<defaultValue>false</defaultValue>
<description>True when this person has a verified OpenCode Console account. Existing records are not backfilled.</description>
<label>Has Console Account</label>
<type>Checkbox</type>
</CustomField>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<description>Read and update Console account identity on Leads and Contacts.</description>
<fieldPermissions><editable>true</editable><field>Lead.Has_Console_Account__c</field><readable>true</readable></fieldPermissions>
<fieldPermissions><editable>true</editable><field>Lead.Console_Account_ID__c</field><readable>true</readable></fieldPermissions>
<fieldPermissions><editable>true</editable><field>Lead.Console_Signup_Date__c</field><readable>true</readable></fieldPermissions>
<fieldPermissions><editable>true</editable><field>Contact.Has_Console_Account__c</field><readable>true</readable></fieldPermissions>
<fieldPermissions><editable>true</editable><field>Contact.Console_Account_ID__c</field><readable>true</readable></fieldPermissions>
<fieldPermissions><editable>true</editable><field>Contact.Console_Signup_Date__c</field><readable>true</readable></fieldPermissions>
<label>Console Account Sync</label>
</PermissionSet>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<StandardValueSet xmlns="http://soap.sforce.com/2006/04/metadata">
<sorted>false</sorted>
<standardValue>
<fullName>Advertisement</fullName>
<default>false</default>
<label>Advertisement</label>
</standardValue>
<standardValue>
<fullName>Customer Event</fullName>
<default>false</default>
<label>Customer Event</label>
</standardValue>
<standardValue>
<fullName>Employee Referral</fullName>
<default>false</default>
<label>Employee Referral</label>
</standardValue>
<standardValue>
<fullName>External Referral</fullName>
<default>false</default>
<label>External Referral</label>
</standardValue>
<standardValue>
<fullName>Google AdWords</fullName>
<default>false</default>
<label>Google AdWords</label>
</standardValue>
<standardValue>
<fullName>Other</fullName>
<default>false</default>
<label>Other</label>
</standardValue>
<standardValue>
<fullName>Partner</fullName>
<default>false</default>
<label>Partner</label>
</standardValue>
<standardValue>
<fullName>Purchased List</fullName>
<default>false</default>
<label>Purchased List</label>
</standardValue>
<standardValue>
<fullName>Trade Show</fullName>
<default>false</default>
<label>Trade Show</label>
</standardValue>
<standardValue>
<fullName>Webinar</fullName>
<default>false</default>
<label>Webinar</label>
</standardValue>
<standardValue>
<fullName>Website</fullName>
<default>false</default>
<label>Website</label>
</standardValue>
<standardValue>
<fullName>enterprise website form</fullName>
<default>false</default>
<label>enterprise website form</label>
</standardValue>
<standardValue>
<fullName>console signup</fullName>
<default>false</default>
<label>console signup</label>
</standardValue>
</StandardValueSet>

View file

@ -0,0 +1,6 @@
{
"packageDirectories": [{ "path": "force-app", "default": true }],
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "64.0"
}