Skip to content

Commit 1515ff9

Browse files
committed
feat(hubspot): property autocomplete, multi-filter, property-changed, list-membership, pipeline/owner dropdowns
1 parent 399927e commit 1515ff9

8 files changed

Lines changed: 1267 additions & 174 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { hubspotListsSelectorContract } from '@/lib/api/contracts/selectors/hubspot'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
const logger = createLogger('HubSpotListsAPI')
13+
14+
interface HubSpotList {
15+
listId: string
16+
name: string
17+
objectTypeId?: string
18+
processingType?: string
19+
deletedAt?: string | null
20+
}
21+
22+
export const GET = withRouteHandler(async (request: NextRequest) => {
23+
const requestId = generateRequestId()
24+
25+
try {
26+
const parsed = await parseRequest(hubspotListsSelectorContract, request, {})
27+
if (!parsed.success) return parsed.response
28+
const { credentialId, objectTypeId, query } = parsed.data.query
29+
30+
const authz = await authorizeCredentialUse(request, {
31+
credentialId,
32+
requireWorkflowIdForInternal: false,
33+
})
34+
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
35+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
36+
}
37+
38+
const accessToken = await refreshAccessTokenIfNeeded(
39+
credentialId,
40+
authz.credentialOwnerUserId,
41+
requestId
42+
)
43+
if (!accessToken) {
44+
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
45+
}
46+
47+
const params = new URLSearchParams()
48+
if (objectTypeId) params.set('objectTypeId', objectTypeId as string)
49+
params.set('count', '500')
50+
51+
const response = await fetch(
52+
`https://api.hubapi.com/crm/v3/lists/search?${params.toString()}`,
53+
{
54+
method: 'POST',
55+
headers: {
56+
Authorization: `Bearer ${accessToken}`,
57+
'Content-Type': 'application/json',
58+
},
59+
body: JSON.stringify({
60+
query: '',
61+
processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'],
62+
...(objectTypeId ? { additionalProperties: ['hs_object_id'] } : {}),
63+
}),
64+
}
65+
)
66+
67+
if (!response.ok) {
68+
const errorText = await response.text().catch(() => '')
69+
logger.error(`[${requestId}] HubSpot lists API error ${response.status}: ${errorText}`)
70+
return NextResponse.json(
71+
{ error: errorText || 'Failed to fetch HubSpot lists' },
72+
{ status: response.status }
73+
)
74+
}
75+
76+
const data = (await response.json()) as { lists?: HubSpotList[] }
77+
const filterTerm = (query as string | undefined)?.toLowerCase()
78+
const lists = (data.lists ?? [])
79+
.filter((l) => !l.deletedAt)
80+
.map((l) => ({
81+
id: l.listId,
82+
name: l.name,
83+
objectType: l.objectTypeId,
84+
processingType: l.processingType,
85+
}))
86+
.filter(
87+
(l) =>
88+
!filterTerm ||
89+
l.id.toLowerCase().includes(filterTerm) ||
90+
l.name.toLowerCase().includes(filterTerm)
91+
)
92+
.sort((a, b) => a.name.localeCompare(b.name))
93+
94+
return NextResponse.json({ lists }, { status: 200 })
95+
} catch (error) {
96+
logger.error(`[${requestId}] Error fetching HubSpot lists:`, error)
97+
return NextResponse.json({ error: 'Failed to fetch HubSpot lists' }, { status: 500 })
98+
}
99+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { hubspotOwnersSelectorContract } from '@/lib/api/contracts/selectors/hubspot'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
const logger = createLogger('HubSpotOwnersAPI')
13+
14+
interface HubSpotOwner {
15+
id: string
16+
email?: string
17+
firstName?: string
18+
lastName?: string
19+
archived?: boolean
20+
}
21+
22+
export const GET = withRouteHandler(async (request: NextRequest) => {
23+
const requestId = generateRequestId()
24+
25+
try {
26+
const parsed = await parseRequest(hubspotOwnersSelectorContract, request, {})
27+
if (!parsed.success) return parsed.response
28+
const { credentialId, query } = parsed.data.query
29+
30+
const authz = await authorizeCredentialUse(request, {
31+
credentialId,
32+
requireWorkflowIdForInternal: false,
33+
})
34+
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
35+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
36+
}
37+
38+
const accessToken = await refreshAccessTokenIfNeeded(
39+
credentialId,
40+
authz.credentialOwnerUserId,
41+
requestId
42+
)
43+
if (!accessToken) {
44+
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
45+
}
46+
47+
const collected: HubSpotOwner[] = []
48+
let after: string | undefined
49+
let pages = 0
50+
do {
51+
const params = new URLSearchParams({ limit: '100' })
52+
if (after) params.set('after', after)
53+
const response = await fetch(`https://api.hubapi.com/crm/v3/owners?${params.toString()}`, {
54+
headers: { Authorization: `Bearer ${accessToken}` },
55+
})
56+
57+
if (!response.ok) {
58+
const errorText = await response.text().catch(() => '')
59+
logger.error(`[${requestId}] HubSpot owners API error ${response.status}: ${errorText}`)
60+
return NextResponse.json(
61+
{ error: errorText || 'Failed to fetch HubSpot owners' },
62+
{ status: response.status }
63+
)
64+
}
65+
66+
const data = (await response.json()) as {
67+
results?: HubSpotOwner[]
68+
paging?: { next?: { after?: string } }
69+
}
70+
if (data.results?.length) collected.push(...data.results)
71+
after = data.paging?.next?.after
72+
pages++
73+
} while (after && pages < 10)
74+
75+
const filterTerm = (query as string | undefined)?.toLowerCase()
76+
const owners = collected
77+
.filter((o) => !o.archived)
78+
.map((o) => ({
79+
id: o.id,
80+
name: [o.firstName, o.lastName].filter(Boolean).join(' ') || o.email || o.id,
81+
email: o.email,
82+
}))
83+
.filter(
84+
(o) =>
85+
!filterTerm ||
86+
o.name.toLowerCase().includes(filterTerm) ||
87+
(o.email?.toLowerCase().includes(filterTerm) ?? false)
88+
)
89+
.sort((a, b) => a.name.localeCompare(b.name))
90+
91+
return NextResponse.json({ owners }, { status: 200 })
92+
} catch (error) {
93+
logger.error(`[${requestId}] Error fetching HubSpot owners:`, error)
94+
return NextResponse.json({ error: 'Failed to fetch HubSpot owners' }, { status: 500 })
95+
}
96+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { hubspotPipelinesSelectorContract } from '@/lib/api/contracts/selectors/hubspot'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
const logger = createLogger('HubSpotPipelinesAPI')
13+
14+
const BUILT_IN_PATH: Record<string, string> = {
15+
contact: 'contacts',
16+
company: 'companies',
17+
deal: 'deals',
18+
ticket: 'tickets',
19+
}
20+
21+
interface HubSpotPipeline {
22+
id: string
23+
label: string
24+
stages?: Array<{ id: string; label: string }>
25+
archived?: boolean
26+
}
27+
28+
export const GET = withRouteHandler(async (request: NextRequest) => {
29+
const requestId = generateRequestId()
30+
31+
try {
32+
const parsed = await parseRequest(hubspotPipelinesSelectorContract, request, {})
33+
if (!parsed.success) return parsed.response
34+
const { credentialId, objectType } = parsed.data.query
35+
36+
const authz = await authorizeCredentialUse(request, {
37+
credentialId,
38+
requireWorkflowIdForInternal: false,
39+
})
40+
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
41+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
42+
}
43+
44+
const accessToken = await refreshAccessTokenIfNeeded(
45+
credentialId,
46+
authz.credentialOwnerUserId,
47+
requestId
48+
)
49+
if (!accessToken) {
50+
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
51+
}
52+
53+
const pathSegment = BUILT_IN_PATH[objectType] ?? objectType
54+
const response = await fetch(
55+
`https://api.hubapi.com/crm/v3/pipelines/${encodeURIComponent(pathSegment)}`,
56+
{ headers: { Authorization: `Bearer ${accessToken}` } }
57+
)
58+
59+
if (!response.ok) {
60+
const errorText = await response.text().catch(() => '')
61+
logger.error(`[${requestId}] HubSpot pipelines API error ${response.status}: ${errorText}`)
62+
return NextResponse.json(
63+
{ error: errorText || 'Failed to fetch HubSpot pipelines' },
64+
{ status: response.status }
65+
)
66+
}
67+
68+
const data = (await response.json()) as { results?: HubSpotPipeline[] }
69+
const pipelines = (data.results ?? [])
70+
.filter((p) => !p.archived)
71+
.map((p) => ({
72+
id: p.id,
73+
name: p.label,
74+
stages: p.stages?.map((s) => ({ id: s.id, label: s.label })),
75+
}))
76+
.sort((a, b) => a.name.localeCompare(b.name))
77+
78+
return NextResponse.json({ pipelines }, { status: 200 })
79+
} catch (error) {
80+
logger.error(`[${requestId}] Error fetching HubSpot pipelines:`, error)
81+
return NextResponse.json({ error: 'Failed to fetch HubSpot pipelines' }, { status: 500 })
82+
}
83+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
const logger = createLogger('HubSpotPropertiesAPI')
13+
14+
const BUILT_IN_PATH: Record<string, string> = {
15+
contact: 'contacts',
16+
company: 'companies',
17+
deal: 'deals',
18+
ticket: 'tickets',
19+
}
20+
21+
interface HubSpotProperty {
22+
name: string
23+
label: string
24+
type?: string
25+
fieldType?: string
26+
groupName?: string
27+
hidden?: boolean
28+
archived?: boolean
29+
}
30+
31+
export const GET = withRouteHandler(async (request: NextRequest) => {
32+
const requestId = generateRequestId()
33+
34+
try {
35+
const parsed = await parseRequest(hubspotPropertiesSelectorContract, request, {})
36+
if (!parsed.success) return parsed.response
37+
const { credentialId, objectType, query } = parsed.data.query
38+
39+
const authz = await authorizeCredentialUse(request, {
40+
credentialId,
41+
requireWorkflowIdForInternal: false,
42+
})
43+
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
44+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
45+
}
46+
47+
const accessToken = await refreshAccessTokenIfNeeded(
48+
credentialId,
49+
authz.credentialOwnerUserId,
50+
requestId
51+
)
52+
if (!accessToken) {
53+
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
54+
}
55+
56+
const pathSegment = BUILT_IN_PATH[objectType] ?? objectType
57+
const response = await fetch(
58+
`https://api.hubapi.com/crm/v3/properties/${encodeURIComponent(pathSegment)}`,
59+
{ headers: { Authorization: `Bearer ${accessToken}` } }
60+
)
61+
62+
if (!response.ok) {
63+
const errorText = await response.text().catch(() => '')
64+
logger.error(`[${requestId}] HubSpot properties API error ${response.status}: ${errorText}`)
65+
return NextResponse.json(
66+
{ error: errorText || 'Failed to fetch HubSpot properties' },
67+
{ status: response.status }
68+
)
69+
}
70+
71+
const data = (await response.json()) as { results?: HubSpotProperty[] }
72+
if (!Array.isArray(data.results)) {
73+
return NextResponse.json({ error: 'Invalid HubSpot properties response' }, { status: 500 })
74+
}
75+
76+
const filterTerm = (query as string | undefined)?.toLowerCase()
77+
const properties = data.results
78+
.filter((p) => !p.hidden && !p.archived)
79+
.map((p) => ({
80+
id: p.name,
81+
name: p.label || p.name,
82+
type: p.type,
83+
fieldType: p.fieldType,
84+
groupName: p.groupName,
85+
}))
86+
.filter(
87+
(p) =>
88+
!filterTerm ||
89+
p.id.toLowerCase().includes(filterTerm) ||
90+
p.name.toLowerCase().includes(filterTerm)
91+
)
92+
.sort((a, b) => a.name.localeCompare(b.name))
93+
94+
return NextResponse.json({ properties }, { status: 200 })
95+
} catch (error) {
96+
logger.error(`[${requestId}] Error fetching HubSpot properties:`, error)
97+
return NextResponse.json({ error: 'Failed to fetch HubSpot properties' }, { status: 500 })
98+
}
99+
})

0 commit comments

Comments
 (0)