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
2 changes: 1 addition & 1 deletion apps/sim/lib/selectors/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const selectorManifest = {
readiness: { all: ['oauthCredential', 'spreadsheetId'] },
}),
'harmonic.savedSearches': providerSelector([], { detail: true, unknownDetail: true }),
'hubspot.lists': providerSelector(),
'hubspot.lists': providerSelector([], { listMode: 'paginated', search: true, detail: true }),
'hubspot.owners': providerSelector(),
'hubspot.pipelines': providerSelector(['objectType', 'customObjectTypeId']),
'hubspot.pipelineStages': providerSelector(['objectType', 'customObjectTypeId', 'pipelineId'], {
Expand Down
116 changes: 116 additions & 0 deletions apps/sim/lib/selectors/server/providers/hubspot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @vitest-environment node
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockFetch, mockResolveSelectorOAuthAccessToken } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockResolveSelectorOAuthAccessToken: vi.fn(),
}))

vi.mock('@/lib/selectors/server/credentials', () => ({
resolveSelectorOAuthAccessToken: mockResolveSelectorOAuthAccessToken,
}))

import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values'
import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot'
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'

function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs {
return {
selectorKey: 'hubspot.lists',
context: { oauthCredential: 'credential-1' },
request,
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
workspaceId: 'workspace-1',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
requesterUserId: 'user-1',
credential: { suppliedId: 'credential-1' },
references: new Map(),
protectedValues: createSelectorProtectedValues(),
}
}

describe('HubSpot server selector adapter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token')
})

afterAll(() => vi.unstubAllGlobals())

it('preserves list search and follows the response offset on demand', async () => {
mockFetch
.mockResolvedValueOnce(
new Response(
JSON.stringify({
hasMore: true,
lists: [{ listId: 'list-1', name: 'Revenue prospects' }],
offset: 500,
total: 501,
}),
{ status: 200 }
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
hasMore: false,
lists: [{ listId: 'list-2', name: 'Revenue customers' }],
offset: 501,
total: 501,
}),
{ status: 200 }
)
)

const first = await hubspotSelectorAttachments['hubspot.lists'].execute(
args({ kind: 'list', search: ' Revenue ' })
)
const second = await hubspotSelectorAttachments['hubspot.lists'].execute(
args({ kind: 'list', search: ' Revenue ', cursor: '500' })
)

expect(first).toEqual({
kind: 'list',
items: [{ id: 'list-1', label: 'Revenue prospects' }],
nextCursor: '500',
})
expect(second).toEqual({
kind: 'list',
items: [{ id: 'list-2', label: 'Revenue customers' }],
})
expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/lists/search')
expect(JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body))).toEqual({
count: 500,
offset: 0,
query: 'Revenue',
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
})
expect(JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body))).toEqual({
count: 500,
offset: 500,
query: 'Revenue',
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
})
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('hydrates a selected list directly by id', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ list: { listId: '123', name: 'Revenue prospects' } }), {
status: 200,
})
)

await expect(
hubspotSelectorAttachments['hubspot.lists'].execute(args({ kind: 'detail', id: '123' }))
).resolves.toEqual({
kind: 'detail',
item: { id: '123', label: 'Revenue prospects' },
})
expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/lists/123')
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})
63 changes: 56 additions & 7 deletions apps/sim/lib/selectors/server/providers/hubspot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { z } from 'zod'
import { getScopesForService } from '@/lib/oauth/utils'
import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits'
import type { ServerSelectorKey } from '@/lib/selectors/manifest'
import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials'
import {
Expand All @@ -8,6 +10,7 @@ import {
} from '@/lib/selectors/server/errors'
import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
import {
detailSelectorResult,
type ExecuteServerSelectorArgs,
listSelectorResult,
requireListRequest,
Expand All @@ -30,6 +33,24 @@ const BUILT_IN_PATH: Record<string, string> = {
ticket: 'tickets',
}

const HUBSPOT_LISTS_PAGE_SIZE = 500

const hubspotListSchema = z.object({
listId: z.string().min(1).max(100),
name: z.string().min(1).max(1_000),
deletedAt: z.string().nullable().optional(),
})

const hubspotListsPageSchema = z.object({
hasMore: z.boolean(),
lists: z.array(hubspotListSchema).max(HUBSPOT_LISTS_PAGE_SIZE),
offset: z.number().int().nonnegative(),
})

const hubspotListDetailSchema = z.object({
list: hubspotListSchema,
})

function resolveObjectType(args: ExecuteServerSelectorArgs): string | null {
const selected = args.context.objectType ?? 'contact'
if (selected !== 'custom') return selected
Expand Down Expand Up @@ -78,27 +99,55 @@ async function executeProperties(args: ExecuteServerSelectorArgs) {
}

async function executeLists(args: ExecuteServerSelectorArgs) {
requireListRequest(args.selectorKey, args.request)
const accessToken = await hubspotToken(args)
const data = await fetchProviderJson<{
lists?: Array<{ listId: string; name: string; deletedAt?: string | null }>
}>('https://api.hubapi.com/crm/v3/lists/search?count=500', {
if (args.request.kind === 'detail') {
const listId = args.request.id.trim()
if (!listId || listId.length > 100) throw new SelectorContextUnavailableError()
const body = await fetchProviderJson<unknown>(
`https://api.hubapi.com/crm/v3/lists/${encodeURIComponent(listId)}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
signal: args.signal,
}
)
const parsed = hubspotListDetailSchema.safeParse(body)
if (!parsed.success) throw new SelectorOptionsUnavailableError()
const list = parsed.data.list
return detailSelectorResult(list.deletedAt ? null : { id: args.request.id, label: list.name })
}

requireListRequest(args.selectorKey, args.request)
const cursor = args.request.cursor
if (cursor && !/^\d{1,10}$/.test(cursor)) throw new SelectorContextUnavailableError()
const offset = cursor ? Number(cursor) : 0
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_SELECTOR_OPTIONS) {
throw new SelectorContextUnavailableError()
}
const search = args.request.search?.trim()
const body = await fetchProviderJson<unknown>('https://api.hubapi.com/crm/v3/lists/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '',
count: HUBSPOT_LISTS_PAGE_SIZE,
offset,
...(search ? { query: search } : {}),
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
}),
signal: args.signal,
})
const parsed = hubspotListsPageSchema.safeParse(body)
if (!parsed.success) throw new SelectorOptionsUnavailableError()
const data = parsed.data
if (data.hasMore && data.offset <= offset) throw new SelectorOptionsUnavailableError()
return listSelectorResult(
(data.lists ?? [])
data.lists
.filter((list) => !list.deletedAt && list.listId && list.name)
.map((list) => ({ id: list.listId, label: list.name }))
.sort((left, right) => left.label.localeCompare(right.label))
.sort((left, right) => left.label.localeCompare(right.label)),
data.hasMore ? String(data.offset) : undefined
)
}

Expand Down
61 changes: 61 additions & 0 deletions apps/sim/lib/selectors/server/providers/pipedrive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @vitest-environment node
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockFetch, mockResolveSelectorCredentialBundle } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockResolveSelectorCredentialBundle: vi.fn(),
}))

vi.mock('@/lib/selectors/server/providers/credential-bundle', () => ({
resolveSelectorCredentialBundle: mockResolveSelectorCredentialBundle,
}))

import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values'
import { pipedriveSelectorAttachments } from '@/lib/selectors/server/providers/pipedrive'
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'

function args(): ExecuteServerSelectorArgs {
return {
selectorKey: 'pipedrive.pipelines',
context: { oauthCredential: 'credential-1' },
request: { kind: 'list' },
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
workspaceId: 'workspace-1',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
requesterUserId: 'user-1',
credential: { suppliedId: 'credential-1' },
references: new Map(),
protectedValues: createSelectorProtectedValues(),
}
}

describe('Pipedrive server selector adapter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockResolveSelectorCredentialBundle.mockResolvedValue({ accessToken: 'server-only-token' })
})

afterAll(() => vi.unstubAllGlobals())

it('rejects a semantic failure instead of returning an empty pipeline list', async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: 'Requested service is not available',
error_info: 'Please check developers.pipedrive.com',
data: null,
additional_data: null,
}),
{ status: 200 }
)
)

await expect(
pipedriveSelectorAttachments['pipedrive.pipelines'].execute(args())
).rejects.toMatchObject({ name: 'SelectorOptionsUnavailableError' })
})
})
1 change: 1 addition & 0 deletions apps/sim/triggers/hubspot/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
description: 'The HubSpot list to watch for new members.',
placeholder: 'Select a list',
dependsOn: ['triggerCredentials'],
searchable: true,
required: { field: 'objectType', value: 'list_membership' },
mode: 'trigger',
condition: { field: 'objectType', value: 'list_membership' },
Expand Down
Loading