diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 20b179f71df..34cb8141983 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -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'], { diff --git a/apps/sim/lib/selectors/server/providers/hubspot.test.ts b/apps/sim/lib/selectors/server/providers/hubspot.test.ts new file mode 100644 index 00000000000..343275a9a4d --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/hubspot.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/hubspot.ts b/apps/sim/lib/selectors/server/providers/hubspot.ts index 6fc5be7243c..8a1995db5f6 100644 --- a/apps/sim/lib/selectors/server/providers/hubspot.ts +++ b/apps/sim/lib/selectors/server/providers/hubspot.ts @@ -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 { @@ -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, @@ -30,6 +33,24 @@ const BUILT_IN_PATH: Record = { 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 @@ -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( + `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('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 ) } diff --git a/apps/sim/lib/selectors/server/providers/pipedrive.test.ts b/apps/sim/lib/selectors/server/providers/pipedrive.test.ts new file mode 100644 index 00000000000..d09256e27c4 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/pipedrive.test.ts @@ -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' }) + }) +}) diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index a0375d93f76..5b2b1214696 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -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' },