Skip to content

Commit 616d5e9

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
fix(selectors): paginate HubSpot list options (#7327)
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent f4f1850 commit 616d5e9

5 files changed

Lines changed: 235 additions & 8 deletions

File tree

apps/sim/lib/selectors/manifest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const selectorManifest = {
159159
readiness: { all: ['oauthCredential', 'spreadsheetId'] },
160160
}),
161161
'harmonic.savedSearches': providerSelector([], { detail: true, unknownDetail: true }),
162-
'hubspot.lists': providerSelector(),
162+
'hubspot.lists': providerSelector([], { listMode: 'paginated', search: true, detail: true }),
163163
'hubspot.owners': providerSelector(),
164164
'hubspot.pipelines': providerSelector(['objectType', 'customObjectTypeId']),
165165
'hubspot.pipelineStages': providerSelector(['objectType', 'customObjectTypeId', 'pipelineId'], {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockFetch, mockResolveSelectorOAuthAccessToken } = vi.hoisted(() => ({
7+
mockFetch: vi.fn(),
8+
mockResolveSelectorOAuthAccessToken: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/selectors/server/credentials', () => ({
12+
resolveSelectorOAuthAccessToken: mockResolveSelectorOAuthAccessToken,
13+
}))
14+
15+
import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values'
16+
import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot'
17+
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'
18+
19+
function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs {
20+
return {
21+
selectorKey: 'hubspot.lists',
22+
context: { oauthCredential: 'credential-1' },
23+
request,
24+
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
25+
workspaceId: 'workspace-1',
26+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
27+
requesterUserId: 'user-1',
28+
credential: { suppliedId: 'credential-1' },
29+
references: new Map(),
30+
protectedValues: createSelectorProtectedValues(),
31+
}
32+
}
33+
34+
describe('HubSpot server selector adapter', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
vi.stubGlobal('fetch', mockFetch)
38+
mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token')
39+
})
40+
41+
afterAll(() => vi.unstubAllGlobals())
42+
43+
it('preserves list search and follows the response offset on demand', async () => {
44+
mockFetch
45+
.mockResolvedValueOnce(
46+
new Response(
47+
JSON.stringify({
48+
hasMore: true,
49+
lists: [{ listId: 'list-1', name: 'Revenue prospects' }],
50+
offset: 500,
51+
total: 501,
52+
}),
53+
{ status: 200 }
54+
)
55+
)
56+
.mockResolvedValueOnce(
57+
new Response(
58+
JSON.stringify({
59+
hasMore: false,
60+
lists: [{ listId: 'list-2', name: 'Revenue customers' }],
61+
offset: 501,
62+
total: 501,
63+
}),
64+
{ status: 200 }
65+
)
66+
)
67+
68+
const first = await hubspotSelectorAttachments['hubspot.lists'].execute(
69+
args({ kind: 'list', search: ' Revenue ' })
70+
)
71+
const second = await hubspotSelectorAttachments['hubspot.lists'].execute(
72+
args({ kind: 'list', search: ' Revenue ', cursor: '500' })
73+
)
74+
75+
expect(first).toEqual({
76+
kind: 'list',
77+
items: [{ id: 'list-1', label: 'Revenue prospects' }],
78+
nextCursor: '500',
79+
})
80+
expect(second).toEqual({
81+
kind: 'list',
82+
items: [{ id: 'list-2', label: 'Revenue customers' }],
83+
})
84+
expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/lists/search')
85+
expect(JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body))).toEqual({
86+
count: 500,
87+
offset: 0,
88+
query: 'Revenue',
89+
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
90+
})
91+
expect(JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body))).toEqual({
92+
count: 500,
93+
offset: 500,
94+
query: 'Revenue',
95+
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
96+
})
97+
expect(mockFetch).toHaveBeenCalledTimes(2)
98+
})
99+
100+
it('hydrates a selected list directly by id', async () => {
101+
mockFetch.mockResolvedValueOnce(
102+
new Response(JSON.stringify({ list: { listId: '123', name: 'Revenue prospects' } }), {
103+
status: 200,
104+
})
105+
)
106+
107+
await expect(
108+
hubspotSelectorAttachments['hubspot.lists'].execute(args({ kind: 'detail', id: '123' }))
109+
).resolves.toEqual({
110+
kind: 'detail',
111+
item: { id: '123', label: 'Revenue prospects' },
112+
})
113+
expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/lists/123')
114+
expect(mockFetch).toHaveBeenCalledTimes(1)
115+
})
116+
})

apps/sim/lib/selectors/server/providers/hubspot.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { z } from 'zod'
12
import { getScopesForService } from '@/lib/oauth/utils'
3+
import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits'
24
import type { ServerSelectorKey } from '@/lib/selectors/manifest'
35
import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials'
46
import {
@@ -8,6 +10,7 @@ import {
810
} from '@/lib/selectors/server/errors'
911
import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
1012
import {
13+
detailSelectorResult,
1114
type ExecuteServerSelectorArgs,
1215
listSelectorResult,
1316
requireListRequest,
@@ -30,6 +33,24 @@ const BUILT_IN_PATH: Record<string, string> = {
3033
ticket: 'tickets',
3134
}
3235

36+
const HUBSPOT_LISTS_PAGE_SIZE = 500
37+
38+
const hubspotListSchema = z.object({
39+
listId: z.string().min(1).max(100),
40+
name: z.string().min(1).max(1_000),
41+
deletedAt: z.string().nullable().optional(),
42+
})
43+
44+
const hubspotListsPageSchema = z.object({
45+
hasMore: z.boolean(),
46+
lists: z.array(hubspotListSchema).max(HUBSPOT_LISTS_PAGE_SIZE),
47+
offset: z.number().int().nonnegative(),
48+
})
49+
50+
const hubspotListDetailSchema = z.object({
51+
list: hubspotListSchema,
52+
})
53+
3354
function resolveObjectType(args: ExecuteServerSelectorArgs): string | null {
3455
const selected = args.context.objectType ?? 'contact'
3556
if (selected !== 'custom') return selected
@@ -78,27 +99,55 @@ async function executeProperties(args: ExecuteServerSelectorArgs) {
7899
}
79100

80101
async function executeLists(args: ExecuteServerSelectorArgs) {
81-
requireListRequest(args.selectorKey, args.request)
82102
const accessToken = await hubspotToken(args)
83-
const data = await fetchProviderJson<{
84-
lists?: Array<{ listId: string; name: string; deletedAt?: string | null }>
85-
}>('https://api.hubapi.com/crm/v3/lists/search?count=500', {
103+
if (args.request.kind === 'detail') {
104+
const listId = args.request.id.trim()
105+
if (!listId || listId.length > 100) throw new SelectorContextUnavailableError()
106+
const body = await fetchProviderJson<unknown>(
107+
`https://api.hubapi.com/crm/v3/lists/${encodeURIComponent(listId)}`,
108+
{
109+
headers: { Authorization: `Bearer ${accessToken}` },
110+
signal: args.signal,
111+
}
112+
)
113+
const parsed = hubspotListDetailSchema.safeParse(body)
114+
if (!parsed.success) throw new SelectorOptionsUnavailableError()
115+
const list = parsed.data.list
116+
return detailSelectorResult(list.deletedAt ? null : { id: args.request.id, label: list.name })
117+
}
118+
119+
requireListRequest(args.selectorKey, args.request)
120+
const cursor = args.request.cursor
121+
if (cursor && !/^\d{1,10}$/.test(cursor)) throw new SelectorContextUnavailableError()
122+
const offset = cursor ? Number(cursor) : 0
123+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_SELECTOR_OPTIONS) {
124+
throw new SelectorContextUnavailableError()
125+
}
126+
const search = args.request.search?.trim()
127+
const body = await fetchProviderJson<unknown>('https://api.hubapi.com/crm/v3/lists/search', {
86128
method: 'POST',
87129
headers: {
88130
Authorization: `Bearer ${accessToken}`,
89131
'Content-Type': 'application/json',
90132
},
91133
body: JSON.stringify({
92-
query: '',
134+
count: HUBSPOT_LISTS_PAGE_SIZE,
135+
offset,
136+
...(search ? { query: search } : {}),
93137
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
94138
}),
95139
signal: args.signal,
96140
})
141+
const parsed = hubspotListsPageSchema.safeParse(body)
142+
if (!parsed.success) throw new SelectorOptionsUnavailableError()
143+
const data = parsed.data
144+
if (data.hasMore && data.offset <= offset) throw new SelectorOptionsUnavailableError()
97145
return listSelectorResult(
98-
(data.lists ?? [])
146+
data.lists
99147
.filter((list) => !list.deletedAt && list.listId && list.name)
100148
.map((list) => ({ id: list.listId, label: list.name }))
101-
.sort((left, right) => left.label.localeCompare(right.label))
149+
.sort((left, right) => left.label.localeCompare(right.label)),
150+
data.hasMore ? String(data.offset) : undefined
102151
)
103152
}
104153

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockFetch, mockResolveSelectorCredentialBundle } = vi.hoisted(() => ({
7+
mockFetch: vi.fn(),
8+
mockResolveSelectorCredentialBundle: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/selectors/server/providers/credential-bundle', () => ({
12+
resolveSelectorCredentialBundle: mockResolveSelectorCredentialBundle,
13+
}))
14+
15+
import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values'
16+
import { pipedriveSelectorAttachments } from '@/lib/selectors/server/providers/pipedrive'
17+
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'
18+
19+
function args(): ExecuteServerSelectorArgs {
20+
return {
21+
selectorKey: 'pipedrive.pipelines',
22+
context: { oauthCredential: 'credential-1' },
23+
request: { kind: 'list' },
24+
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
25+
workspaceId: 'workspace-1',
26+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
27+
requesterUserId: 'user-1',
28+
credential: { suppliedId: 'credential-1' },
29+
references: new Map(),
30+
protectedValues: createSelectorProtectedValues(),
31+
}
32+
}
33+
34+
describe('Pipedrive server selector adapter', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
vi.stubGlobal('fetch', mockFetch)
38+
mockResolveSelectorCredentialBundle.mockResolvedValue({ accessToken: 'server-only-token' })
39+
})
40+
41+
afterAll(() => vi.unstubAllGlobals())
42+
43+
it('rejects a semantic failure instead of returning an empty pipeline list', async () => {
44+
mockFetch.mockResolvedValueOnce(
45+
new Response(
46+
JSON.stringify({
47+
success: false,
48+
error: 'Requested service is not available',
49+
error_info: 'Please check developers.pipedrive.com',
50+
data: null,
51+
additional_data: null,
52+
}),
53+
{ status: 200 }
54+
)
55+
)
56+
57+
await expect(
58+
pipedriveSelectorAttachments['pipedrive.pipelines'].execute(args())
59+
).rejects.toMatchObject({ name: 'SelectorOptionsUnavailableError' })
60+
})
61+
})

apps/sim/triggers/hubspot/poller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
6363
description: 'The HubSpot list to watch for new members.',
6464
placeholder: 'Select a list',
6565
dependsOn: ['triggerCredentials'],
66+
searchable: true,
6667
required: { field: 'objectType', value: 'list_membership' },
6768
mode: 'trigger',
6869
condition: { field: 'objectType', value: 'list_membership' },

0 commit comments

Comments
 (0)