Skip to content

Commit caded65

Browse files
committed
feat(auth): append PortalJS to multi-select products field on signup (po-94q)
Twenty person now has a `products` MULTI_SELECT field, split from the existing single-select `source` (first-touch, immutable) — source answers how we acquired a person, products answers which products they use. crm.ts appends 'PORTALJS' on every signup: seeded on create, appended to the existing array (not replaced) on update, and skipped when already present. source's first-touch behavior is untouched. Twenty schema: products field created directly via the CRM API (fieldMetadataId 0bafa9c6-9178-43ee-99c4-b42c7a4b1419), options PORTALJS/DATAHUB. The two PORTALJS_BUILD e2e-evidence rows (bf3c07ec, 5f85a9c5) flipped to STAGING_TEST per po-drk's decision. DataHub's own copied write module is out of scope here per mayor's scope-change on po-94q — handed to a teammate separately.
1 parent 14156e8 commit caded65

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

cloud/auth/src/crm.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
// field metadata, not guessed): PORTALJS_BUILD, PORTALJS_DEMO, PORTALJS_CLOUD,
88
// PORTALJS_NEWSLETTER, PORTALJS_CLI, STAGING_TEST.
99
//
10+
// PRODUCTS (po-94q): `source` and `products` answer different questions and must not be
11+
// conflated. `source` is single-select, first-touch, immutable — HOW we acquired this person.
12+
// `products` is multi-select, additive, never revised downward — WHICH products they use. This
13+
// module is PortalJS's own copied write path (DataHub has its own, byte-identical on the
14+
// match/first-touch logic per po-drk's decision), so it only ever appends the constant
15+
// 'PORTALJS' — every PORTALJS_<SURFACE> source value funnels into the same product.
16+
//
1017
// NEVER throws and NEVER blocks the caller — a CRM failure must degrade exactly like the
1118
// GitHub email lookup does (github.ts): sign-in succeeds regardless, the write is just
1219
// logged as having failed (constraint: failures must be VISIBLE, not swallowed silently).
@@ -48,6 +55,10 @@ export type CrmSource =
4855
| 'PORTALJS_CLI'
4956
| 'STAGING_TEST'
5057

58+
// The Twenty `products` field's option values (po-94q). This module only ever writes
59+
// 'PORTALJS' — DataHub's copied module writes 'DATAHUB' from its own copy of this constant.
60+
const PRODUCT = 'PORTALJS' as const
61+
5162
export interface CrmSignup {
5263
email?: string | null
5364
// GitHub login — fallback match key used only when email is absent.
@@ -82,15 +93,21 @@ function crmHeaders(token: string): Record<string, string> {
8293
return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }
8394
}
8495

96+
interface PersonRecord {
97+
id: string
98+
// Absent on people created before po-94q; null/absent both mean "no products yet".
99+
products?: string[] | null
100+
}
101+
85102
interface PeopleListResponse {
86-
data?: { people?: Array<{ id: string }> }
103+
data?: { people?: PersonRecord[] }
87104
}
88105

89-
async function findPersonId(headers: Record<string, string>, filter: string): Promise<string | null> {
106+
async function findPerson(headers: Record<string, string>, filter: string): Promise<PersonRecord | null> {
90107
const res = await fetch(`${CRM_BASE}/people?filter=${encodeURIComponent(filter)}&limit=1`, { headers })
91108
if (!res.ok) throw new Error(`crm lookup failed: ${res.status}`)
92109
const body = (await res.json()) as PeopleListResponse
93-
return body.data?.people?.[0]?.id ?? null
110+
return body.data?.people?.[0] ?? null
94111
}
95112

96113
// Fire from ctx.waitUntil. Never throws — every failure mode (missing config, network error,
@@ -121,7 +138,10 @@ export async function upsertCrmSignup(env: CrmEnv, signup: CrmSignup): Promise<v
121138
if (org) payload.organization = org
122139

123140
if (env.CRM_ENABLED !== 'true') {
124-
console.log('crm write skipped (CRM_ENABLED off)', JSON.stringify({ filter, payload: { ...payload, source: signup.source } }))
141+
console.log(
142+
'crm write skipped (CRM_ENABLED off)',
143+
JSON.stringify({ filter, payload: { ...payload, source: signup.source, products: [PRODUCT] } })
144+
)
125145
return
126146
}
127147
const token = env.TWENTY_API_TOKEN
@@ -132,9 +152,19 @@ export async function upsertCrmSignup(env: CrmEnv, signup: CrmSignup): Promise<v
132152

133153
try {
134154
const headers = crmHeaders(token)
135-
const personId = await findPersonId(headers, filter)
155+
const existing = await findPerson(headers, filter)
156+
const personId = existing?.id ?? null
136157
const url = personId ? `${CRM_BASE}/people/${personId}` : `${CRM_BASE}/people`
137-
if (!personId) payload.source = signup.source
158+
if (!personId) {
159+
// Create: source is first-touch (set once, here only) and products starts with this one.
160+
payload.source = signup.source
161+
payload.products = [PRODUCT]
162+
} else if (!existing?.products?.includes(PRODUCT)) {
163+
// Update: NEVER touch source (first-touch, po-96l). Append PRODUCT only if this is its
164+
// first signup for that product — Twenty's PATCH sets the field, it doesn't union it, so
165+
// the existing array must be read and re-sent with PRODUCT appended, not replaced.
166+
payload.products = [...(existing?.products ?? []), PRODUCT]
167+
}
138168
const res = await fetch(url, { method: personId ? 'PATCH' : 'POST', headers, body: JSON.stringify(payload) })
139169
if (!res.ok) {
140170
console.error('crm write failed', res.status, await res.text().catch(() => '<no body>'))

cloud/auth/test/crm.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,53 @@ describe('upsertCrmSignup (po-jdr, po-k6n)', () => {
5858
expect(body.source).toBe('PORTALJS_BUILD')
5959
})
6060

61+
// po-94q: `products` is multi-select, distinct from `source` — a new person starts their
62+
// products array with just this write path's product.
63+
it('seeds products with [PORTALJS] on create (po-94q)', async () => {
64+
await upsertCrmSignup(
65+
{ TWENTY_API_TOKEN: 'tok', CRM_ENABLED: 'true' },
66+
{ email: 'new@example.com', source: 'PORTALJS_BUILD' }
67+
)
68+
const body = JSON.parse(calls[1].init.body)
69+
expect(body.products).toEqual(['PORTALJS'])
70+
})
71+
72+
// po-94q: an existing person with no products yet (pre-dates the field, or their first
73+
// product was never PortalJS) gets PORTALJS appended without disturbing other array entries.
74+
it('appends PORTALJS to an existing person\'s products on update, preserving other entries (po-94q)', async () => {
75+
globalThis.fetch = vi.fn(async (url: any, init: any) => {
76+
calls.push({ url: String(url), init })
77+
if (String(url).includes('/people?filter=')) {
78+
return { ok: true, json: async () => ({ data: { people: [{ id: 'person-1', products: ['DATAHUB'] }] } }) } as any
79+
}
80+
return { ok: true } as any
81+
}) as any
82+
await upsertCrmSignup(
83+
{ TWENTY_API_TOKEN: 'tok', CRM_ENABLED: 'true' },
84+
{ email: 'existing@example.com', source: 'PORTALJS_BUILD' }
85+
)
86+
const body = JSON.parse(calls[1].init.body)
87+
expect(body.products).toEqual(['DATAHUB', 'PORTALJS'])
88+
})
89+
90+
// po-94q: a repeat signup for a product the person already has must not resend/duplicate it —
91+
// idempotency for `products` mirrors the idempotency already proven for the person match.
92+
it('omits products from the PATCH payload when PORTALJS is already present (po-94q)', async () => {
93+
globalThis.fetch = vi.fn(async (url: any, init: any) => {
94+
calls.push({ url: String(url), init })
95+
if (String(url).includes('/people?filter=')) {
96+
return { ok: true, json: async () => ({ data: { people: [{ id: 'person-1', products: ['PORTALJS'] }] } }) } as any
97+
}
98+
return { ok: true } as any
99+
}) as any
100+
await upsertCrmSignup(
101+
{ TWENTY_API_TOKEN: 'tok', CRM_ENABLED: 'true' },
102+
{ email: 'existing@example.com', source: 'PORTALJS_BUILD' }
103+
)
104+
const body = JSON.parse(calls[1].init.body)
105+
expect('products' in body).toBe(false)
106+
})
107+
61108
it('PATCHes the existing person id when the lookup finds a match (idempotent)', async () => {
62109
globalThis.fetch = vi.fn(async (url: any, init: any) => {
63110
calls.push({ url: String(url), init })

0 commit comments

Comments
 (0)