diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 632882a2dcf..a1ed7a3e143 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -162,7 +162,7 @@ export const selectorManifest = { }), 'harmonic.savedSearches': providerSelector([], { detail: true, unknownDetail: true }), 'hubspot.lists': providerSelector([], { listMode: 'paginated', search: true, detail: true }), - 'hubspot.owners': providerSelector(), + 'hubspot.owners': providerSelector([], { listMode: 'paginated', detail: true }), 'hubspot.pipelines': providerSelector(['objectType', 'customObjectTypeId']), 'hubspot.pipelineStages': providerSelector(['objectType', 'customObjectTypeId', 'pipelineId'], { readiness: { all: ['oauthCredential', 'pipelineId'] }, diff --git a/apps/sim/lib/selectors/server/providers/hubspot.test.ts b/apps/sim/lib/selectors/server/providers/hubspot.test.ts index 343275a9a4d..c421f7189b6 100644 --- a/apps/sim/lib/selectors/server/providers/hubspot.test.ts +++ b/apps/sim/lib/selectors/server/providers/hubspot.test.ts @@ -16,9 +16,12 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected- import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot' import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' -function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs { +function args( + request: ExecuteServerSelectorArgs['request'], + selectorKey: ExecuteServerSelectorArgs['selectorKey'] = 'hubspot.lists' +): ExecuteServerSelectorArgs { return { - selectorKey: 'hubspot.lists', + selectorKey, context: { oauthCredential: 'credential-1' }, request, scope: { kind: 'workspace', workspaceId: 'workspace-1' }, @@ -113,4 +116,67 @@ describe('HubSpot server selector adapter', () => { expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/lists/123') expect(mockFetch).toHaveBeenCalledTimes(1) }) + + it('paginates active owners through the HubSpot continuation cursor on demand', async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { id: '100', firstName: 'Former', lastName: 'Owner', archived: true }, + { id: '101', firstName: 'Ada', lastName: 'Lovelace', archived: false }, + ], + paging: { next: { after: 'owner-page-2' } }, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ results: [{ id: '102', email: 'grace@example.com' }] }), { + status: 200, + }) + ) + + const first = await hubspotSelectorAttachments['hubspot.owners'].execute( + args({ kind: 'list' }, 'hubspot.owners') + ) + const second = await hubspotSelectorAttachments['hubspot.owners'].execute( + args({ kind: 'list', cursor: 'owner-page-2' }, 'hubspot.owners') + ) + + expect(first).toEqual({ + kind: 'list', + items: [{ id: '101', label: 'Ada Lovelace' }], + nextCursor: 'owner-page-2', + }) + expect(second).toEqual({ + kind: 'list', + items: [{ id: '102', label: 'grace@example.com' }], + }) + const firstUrl = new URL(String(mockFetch.mock.calls[0]?.[0])) + const secondUrl = new URL(String(mockFetch.mock.calls[1]?.[0])) + expect(firstUrl.searchParams.get('limit')).toBe('100') + expect(firstUrl.searchParams.has('after')).toBe(false) + expect(secondUrl.searchParams.get('after')).toBe('owner-page-2') + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('hydrates a selected owner directly by id', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: '777', firstName: 'Katherine', lastName: 'Johnson' }), { + status: 200, + }) + ) + + await expect( + hubspotSelectorAttachments['hubspot.owners'].execute( + args({ kind: 'detail', id: '000777' }, 'hubspot.owners') + ) + ).resolves.toEqual({ + kind: 'detail', + item: { id: '000777', label: 'Katherine Johnson' }, + }) + expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://api.hubapi.com/crm/v3/owners/000777') + 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 8a1995db5f6..be1b0d69331 100644 --- a/apps/sim/lib/selectors/server/providers/hubspot.ts +++ b/apps/sim/lib/selectors/server/providers/hubspot.ts @@ -158,6 +158,21 @@ interface HubSpotPipeline { archived?: boolean } +interface HubSpotOwner { + id: string + email?: string + firstName?: string + lastName?: string + archived?: boolean +} + +function hubspotOwnerOption(owner: HubSpotOwner) { + return { + id: owner.id, + label: [owner.firstName, owner.lastName].filter(Boolean).join(' ') || owner.email || owner.id, + } +} + async function loadPipelines(args: ExecuteServerSelectorArgs): Promise { const objectType = resolveObjectType(args) if (!objectType) return [] @@ -194,37 +209,36 @@ async function executePipelineStages(args: ExecuteServerSelectorArgs) { } async function executeOwners(args: ExecuteServerSelectorArgs) { - requireListRequest(args.selectorKey, args.request) const accessToken = await hubspotToken(args) - const owners: Array<{ - id: string - email?: string - firstName?: string - lastName?: string - archived?: boolean - }> = [] - let after: string | undefined - for (let page = 0; page < 10; page++) { - const url = new URL('https://api.hubapi.com/crm/v3/owners') - url.searchParams.set('limit', '100') - if (after) url.searchParams.set('after', after) - const data = await fetchProviderJson<{ - results?: typeof owners - paging?: { next?: { after?: string } } - }>(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal }) - owners.push(...(data.results ?? [])) - after = data.paging?.next?.after - if (!after) break + if (args.request.kind === 'detail') { + const ownerId = args.request.id.trim() + if (!ownerId || ownerId.length > 100) throw new SelectorContextUnavailableError() + const owner = await fetchProviderJson( + `https://api.hubapi.com/crm/v3/owners/${encodeURIComponent(ownerId)}`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: args.signal, + } + ) + return detailSelectorResult( + owner.archived || !owner.id ? null : { ...hubspotOwnerOption(owner), id: ownerId } + ) } + + requireListRequest(args.selectorKey, args.request) + const url = new URL('https://api.hubapi.com/crm/v3/owners') + url.searchParams.set('limit', '100') + if (args.request.cursor) url.searchParams.set('after', args.request.cursor) + const data = await fetchProviderJson<{ + results?: HubSpotOwner[] + paging?: { next?: { after?: string } } + }>(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal }) return listSelectorResult( - owners + (data.results ?? []) .filter((owner) => !owner.archived && owner.id) - .map((owner) => ({ - id: owner.id, - label: - [owner.firstName, owner.lastName].filter(Boolean).join(' ') || owner.email || owner.id, - })) - .sort((left, right) => left.label.localeCompare(right.label)) + .map(hubspotOwnerOption) + .sort((left, right) => left.label.localeCompare(right.label)), + data.paging?.next?.after ) }