Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cloud/auth/src/crm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,16 @@ export async function upsertCrmSignup(env: CrmEnv, signup: CrmSignup): Promise<v
// on, so it keys off login exactly as before. When email is present but no full name was
// captured, omit name entirely rather than writing an empty one over a human-edited value.
const name = fullName ? splitFullName(fullName) : !email ? { firstName: login, lastName: '' } : undefined
const payload: Record<string, unknown> = { source: signup.source }
// source is first-touch attribution: how we acquired this person, set once at creation and
// never revised by a later signup on a different surface (po-96l). It is added to the
// payload below only for the POST (create) branch, never the PATCH (update) branch.
const payload: Record<string, unknown> = {}
if (email) payload.emails = { primaryEmail: email }
if (name) payload.name = name
if (org) payload.organization = org

if (env.CRM_ENABLED !== 'true') {
console.log('crm write skipped (CRM_ENABLED off)', JSON.stringify({ filter, payload }))
console.log('crm write skipped (CRM_ENABLED off)', JSON.stringify({ filter, payload: { ...payload, source: signup.source } }))
return
}
const token = env.TWENTY_API_TOKEN
Expand All @@ -131,6 +134,7 @@ export async function upsertCrmSignup(env: CrmEnv, signup: CrmSignup): Promise<v
const headers = crmHeaders(token)
const personId = await findPersonId(headers, filter)
const url = personId ? `${CRM_BASE}/people/${personId}` : `${CRM_BASE}/people`
if (!personId) payload.source = signup.source
const res = await fetch(url, { method: personId ? 'PATCH' : 'POST', headers, body: JSON.stringify(payload) })
if (!res.ok) {
console.error('crm write failed', res.status, await res.text().catch(() => '<no body>'))
Expand Down
20 changes: 20 additions & 0 deletions cloud/auth/test/crm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ describe('upsertCrmSignup (po-jdr, po-k6n)', () => {
expect(calls[1].init.method).toBe('PATCH')
})

// po-96l: source is first-touch attribution — a later signup on a different surface must
// not rewrite an existing row's source. Only POST (create) may carry source.
it('never includes source in the PATCH payload, so a second surface cannot overwrite first-touch attribution (po-96l)', async () => {
globalThis.fetch = vi.fn(async (url: any, init: any) => {
calls.push({ url: String(url), init })
if (String(url).includes('/people?filter=')) {
return { ok: true, json: async () => ({ data: { people: [{ id: 'person-1' }] } }) } as any
}
return { ok: true } as any
}) as any
await upsertCrmSignup(
{ TWENTY_API_TOKEN: 'tok', CRM_ENABLED: 'true' },
{ email: 'existing@example.com', source: 'PORTALJS_CLI' }
)
const body = JSON.parse(calls[1].init.body)
expect(calls[1].init.method).toBe('PATCH')
expect(body.source).toBeUndefined()
expect('source' in body).toBe(false)
})

it('matches on GitHub login + source when email is absent', async () => {
await upsertCrmSignup(
{ TWENTY_API_TOKEN: 'tok', CRM_ENABLED: 'true' },
Expand Down
Loading