Skip to content

Commit 5fa8416

Browse files
authored
fix(selectors): fetch all pages for paginated dropdown list routes (#4823)
* fix(selectors): fetch all pages for paginated dropdown list routes Dropdown selectors fetched only the first page of paginated provider APIs, silently hiding results past page one. Add bounded server-side draining to the list routes across Microsoft Graph, Google, Notion, Atlassian, Linear, AWS CloudWatch, and offset/token REST APIs, plus a shared client-side drain cap in the selector hook. Response shapes, stored values, and tool execution are unchanged; CloudWatch list tools still honor a caller-supplied limit. Also fixes the Word file picker that was searching for .xlsx files. * fix(selectors): harden JSM and Monday pagination draining - JSM service-desk/request-type drains advance `start` by the actual row count returned (not the fixed page size) and stop on an empty page, so a short non-final page can't skip items. - Monday boards drain now checks `response.ok` per page, surfacing a mid-drain HTTP failure instead of treating it as an empty final page and returning a partial 200. * docs(selectors): clarify JSM drain advances start by actual row count The offset-advancement fix (advance `start` by the rows returned, not the fixed page size) landed in 7b19788; update the TSDoc to match so it no longer reads as advancing by `limit`. * fix(selectors): drain fetchPage in direct fetchList callers Making `fetchList` optional left three direct callers (outside the useSelectorOptions hook) calling it unguarded, which broke the build's type check. Route them through a shared `loadAllSelectorOptions` helper that uses `fetchList` when present and otherwise drains `fetchPage`. This also prevents a regression: `confluence.spaces` / `knowledge.documents` now paginate via `fetchPage` only, and these callers (search/replace, value resolution) would otherwise have silently returned no options. * chore(selectors): rename MAX_PAGE_PAGES to MAX_NOTION_PAGES for readability
1 parent f6685cf commit 5fa8416

46 files changed

Lines changed: 2299 additions & 727 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/auth/oauth/microsoft/files/route.ts

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,57 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
1010
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
11+
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
1112

1213
export const dynamic = 'force-dynamic'
1314

1415
const logger = createLogger('MicrosoftFilesAPI')
1516

1617
/**
17-
* Get Excel files from Microsoft OneDrive
18+
* Microsoft Graph paginates `search()` results via the `@odata.nextLink`
19+
* absolute URL in the response body. Request the largest page (`$top` caps at
20+
* 999) and drain following nextLink, bounded by a page cap.
21+
* See https://learn.microsoft.com/en-us/graph/paging
22+
*/
23+
const MICROSOFT_FILES_PAGE_SIZE = 999
24+
const MAX_MICROSOFT_FILES_PAGES = 20
25+
26+
interface MicrosoftGraphFile {
27+
id: string
28+
name?: string
29+
mimeType?: string
30+
webUrl?: string
31+
size?: number
32+
createdDateTime?: string
33+
lastModifiedDateTime?: string
34+
thumbnails?: Array<{ small?: { url?: string }; medium?: { url?: string } }>
35+
createdBy?: { user?: { displayName?: string; email?: string } }
36+
}
37+
38+
/**
39+
* The shared `/api/auth/oauth/microsoft/files` route serves both the
40+
* `microsoft.excel` and `microsoft.word` selectors. The two are distinguished
41+
* by the `fileType` query parameter the selector forwards (defaulting to
42+
* `excel` for backward compatibility), which drives both the search-query
43+
* extension hint and the server-side result filter.
44+
*/
45+
const FILE_TYPE_CONFIG = {
46+
excel: {
47+
extension: '.xlsx',
48+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49+
},
50+
word: {
51+
extension: '.docx',
52+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
53+
},
54+
} as const
55+
56+
type MicrosoftFileType = keyof typeof FILE_TYPE_CONFIG
57+
58+
/**
59+
* Get Excel or Word files from Microsoft OneDrive / SharePoint. The
60+
* `fileType` query parameter selects which Office document type to return
61+
* (defaults to `excel`).
1862
*/
1963
export const GET = withRouteHandler(async (request: NextRequest) => {
2064
const requestId = generateRequestId()
@@ -27,6 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2771
query: searchParams.get('query') ?? undefined,
2872
driveId: searchParams.get('driveId') ?? undefined,
2973
workflowId: searchParams.get('workflowId') ?? undefined,
74+
fileType: searchParams.get('fileType') ?? undefined,
3075
})
3176

3277
if (!parsedQuery.success) {
@@ -40,6 +85,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4085
const { credentialId, driveId, workflowId } = parsedQuery.data
4186
const query = parsedQuery.data.query ?? ''
4287

88+
const fileType: MicrosoftFileType = parsedQuery.data.fileType ?? 'excel'
89+
const { extension, mimeType: targetMimeType } = FILE_TYPE_CONFIG[fileType]
90+
4391
const authz = await authorizeCredentialUse(request, {
4492
credentialId,
4593
workflowId,
@@ -72,19 +120,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
72120
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
73121
}
74122

75-
// Build search query for Excel files
76-
let searchQuery = '.xlsx'
77-
if (query) {
78-
searchQuery = `${query} .xlsx`
79-
}
123+
// Build search query for the requested Office document type
124+
const searchQuery = query ? `${query} ${extension}` : extension
80125

81126
// Build the query parameters for Microsoft Graph API
82127
const searchParams_new = new URLSearchParams()
83128
searchParams_new.append(
84129
'$select',
85130
'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy'
86131
)
87-
searchParams_new.append('$top', '50')
132+
searchParams_new.append('$top', String(MICROSOFT_FILES_PAGE_SIZE))
88133

89134
// When driveId is provided (SharePoint), search within that specific drive.
90135
// Otherwise, search the user's personal OneDrive.
@@ -99,44 +144,57 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
99144
}
100145
const drivePath = driveId ? `drives/${driveId}` : 'me/drive'
101146

102-
const response = await fetch(
103-
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
104-
{
147+
const rawFiles: MicrosoftGraphFile[] = []
148+
let nextUrl: string | undefined =
149+
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`
150+
151+
for (let page = 0; page < MAX_MICROSOFT_FILES_PAGES && nextUrl; page++) {
152+
const response = await fetch(nextUrl, {
105153
headers: {
106154
Authorization: `Bearer ${accessToken}`,
107155
},
156+
})
157+
158+
if (!response.ok) {
159+
const errorData = await response
160+
.json()
161+
.catch(() => ({ error: { message: 'Unknown error' } }))
162+
logger.error(`[${requestId}] Microsoft Graph API error`, {
163+
status: response.status,
164+
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
165+
})
166+
return NextResponse.json(
167+
{
168+
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
169+
},
170+
{ status: response.status }
171+
)
108172
}
109-
)
110173

111-
if (!response.ok) {
112-
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
113-
logger.error(`[${requestId}] Microsoft Graph API error`, {
114-
status: response.status,
115-
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
116-
})
117-
return NextResponse.json(
118-
{
119-
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
120-
},
121-
{ status: response.status }
122-
)
123-
}
174+
const data = await response.json()
175+
rawFiles.push(...((data.value as MicrosoftGraphFile[]) || []))
176+
177+
const nextLink = getGraphNextPageUrl(data)
178+
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
124179

125-
const data = await response.json()
126-
let files = data.value || []
180+
if (nextUrl && page === MAX_MICROSOFT_FILES_PAGES - 1) {
181+
logger.warn(
182+
`[${requestId}] Microsoft files search hit pagination cap; list may be incomplete`,
183+
{ fileType, pages: MAX_MICROSOFT_FILES_PAGES, collected: rawFiles.length }
184+
)
185+
}
186+
}
127187

128-
// Transform Microsoft Graph response to match expected format and filter for Excel files
129-
files = files
188+
// Transform Microsoft Graph response and filter to the requested file type
189+
const files = rawFiles
130190
.filter(
131-
(file: any) =>
132-
file.name?.toLowerCase().endsWith('.xlsx') ||
133-
file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
191+
(file: MicrosoftGraphFile) =>
192+
file.name?.toLowerCase().endsWith(extension) || file.mimeType === targetMimeType
134193
)
135-
.map((file: any) => ({
194+
.map((file: MicrosoftGraphFile) => ({
136195
id: file.id,
137196
name: file.name,
138-
mimeType:
139-
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
197+
mimeType: file.mimeType || targetMimeType,
140198
iconLink: file.thumbnails?.[0]?.small?.url,
141199
webViewLink: file.webUrl,
142200
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
@@ -155,7 +213,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
155213

156214
return NextResponse.json({ files }, { status: 200 })
157215
} catch (error) {
158-
logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error)
216+
logger.error(`[${requestId}] Error fetching files from Microsoft OneDrive`, error)
159217
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
160218
}
161219
})

apps/sim/app/api/tools/airtable/bases/route.ts

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,71 @@ const logger = createLogger('AirtableBasesAPI')
1111

1212
export const dynamic = 'force-dynamic'
1313

14+
const AIRTABLE_MAX_BASES_PAGES = 50
15+
16+
interface AirtableBase {
17+
id: string
18+
name: string
19+
}
20+
21+
/**
22+
* Lists all Airtable bases, following the `offset` continuation token the Meta
23+
* API returns (an opaque string, passed back verbatim as `?offset=`) so the
24+
* full set is returned. Bounded by `AIRTABLE_MAX_BASES_PAGES`; logs a warning
25+
* rather than silently dropping bases when the cap is hit.
26+
*/
27+
async function fetchAllBases(accessToken: string): Promise<AirtableBase[]> {
28+
const bases: AirtableBase[] = []
29+
let offset: string | undefined
30+
31+
for (let page = 0; page < AIRTABLE_MAX_BASES_PAGES; page++) {
32+
const url = new URL('https://api.airtable.com/v0/meta/bases')
33+
if (offset) {
34+
url.searchParams.set('offset', offset)
35+
}
36+
37+
const response = await fetch(url.toString(), {
38+
headers: {
39+
Authorization: `Bearer ${accessToken}`,
40+
'Content-Type': 'application/json',
41+
},
42+
})
43+
44+
if (!response.ok) {
45+
const errorData = await response.json().catch(() => ({}))
46+
throw new AirtableFetchError(response.status, errorData)
47+
}
48+
49+
const data = (await response.json()) as { bases?: AirtableBase[]; offset?: string }
50+
if (Array.isArray(data.bases)) {
51+
bases.push(...data.bases)
52+
}
53+
54+
offset = data.offset || undefined
55+
if (!offset) {
56+
return bases
57+
}
58+
59+
if (page === AIRTABLE_MAX_BASES_PAGES - 1) {
60+
logger.warn('Airtable bases listing hit pagination cap; base list may be incomplete', {
61+
pages: AIRTABLE_MAX_BASES_PAGES,
62+
})
63+
}
64+
}
65+
66+
return bases
67+
}
68+
69+
class AirtableFetchError extends Error {
70+
constructor(
71+
readonly status: number,
72+
readonly details: unknown
73+
) {
74+
super('Failed to fetch Airtable bases')
75+
this.name = 'AirtableFetchError'
76+
}
77+
}
78+
1479
export const POST = withRouteHandler(async (request: NextRequest) => {
1580
const requestId = generateRequestId()
1681
try {
@@ -45,27 +110,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
45110
)
46111
}
47112

48-
const response = await fetch('https://api.airtable.com/v0/meta/bases', {
49-
headers: {
50-
Authorization: `Bearer ${accessToken}`,
51-
'Content-Type': 'application/json',
52-
},
53-
})
54-
55-
if (!response.ok) {
56-
const errorData = await response.json().catch(() => ({}))
57-
logger.error('Failed to fetch Airtable bases', {
58-
status: response.status,
59-
error: errorData,
60-
})
61-
return NextResponse.json(
62-
{ error: 'Failed to fetch Airtable bases', details: errorData },
63-
{ status: response.status }
64-
)
113+
let allBases: AirtableBase[]
114+
try {
115+
allBases = await fetchAllBases(accessToken)
116+
} catch (error) {
117+
if (error instanceof AirtableFetchError) {
118+
logger.error('Failed to fetch Airtable bases', {
119+
status: error.status,
120+
error: error.details,
121+
})
122+
return NextResponse.json(
123+
{ error: 'Failed to fetch Airtable bases', details: error.details },
124+
{ status: error.status }
125+
)
126+
}
127+
throw error
65128
}
66129

67-
const data = await response.json()
68-
const bases = (data.bases || []).map((base: { id: string; name: string }) => ({
130+
const bases = allBases.map((base) => ({
69131
id: base.id,
70132
name: base.name,
71133
}))

0 commit comments

Comments
 (0)