Skip to content

Commit 19f5b2b

Browse files
committed
fix(confluence): include archived spaces in selector dropdown
Confluence v2 /spaces defaults to status=current and the status param is a single-value enum, so archived spaces never surface. They synced fine when entered manually as a spaceKey because the connector looks up spaces via ?keys=<key> which ignores status. Now fetches current and archived in parallel and tags archived ones in the dropdown label.
1 parent 1569355 commit 19f5b2b

3 files changed

Lines changed: 74 additions & 44 deletions

File tree

apps/sim/app/api/tools/confluence/selector-spaces/route.ts

Lines changed: 61 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -86,59 +86,79 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8686
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
8787
const PAGE_LIMIT = 250
8888
const MAX_PAGES = 20
89-
const spaces: { id: string; name: string; key: string }[] = []
90-
let cursor: string | undefined
91-
let pageCount = 0
92-
93-
while (pageCount < MAX_PAGES) {
94-
const params = new URLSearchParams({ limit: String(PAGE_LIMIT) })
95-
if (cursor) params.set('cursor', cursor)
96-
const url = `${baseUrl}?${params.toString()}`
97-
98-
const response = await fetch(url, {
99-
method: 'GET',
100-
headers: {
101-
Accept: 'application/json',
102-
Authorization: `Bearer ${accessToken}`,
103-
},
104-
})
10589

106-
if (!response.ok) {
107-
const errorText = await response.text()
108-
logger.error('Confluence API error response:', {
109-
status: response.status,
110-
statusText: response.statusText,
111-
error: errorText,
90+
/**
91+
* Confluence v2 `/spaces` defaults to `status=current` and treats `status`
92+
* as a single-value enum, so archived spaces never surface from one call.
93+
* Listing both surfaces archived spaces in the dropdown — they would
94+
* otherwise only be reachable by typing the space key manually, even
95+
* though sync works against archived spaces just fine.
96+
*/
97+
async function fetchAllPages(status: 'current' | 'archived'): Promise<{
98+
spaces: { id: string; name: string; key: string; status: string }[]
99+
capped: boolean
100+
}> {
101+
const collected: { id: string; name: string; key: string; status: string }[] = []
102+
let cursor: string | undefined
103+
let pageCount = 0
104+
105+
while (pageCount < MAX_PAGES) {
106+
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
107+
if (cursor) params.set('cursor', cursor)
108+
const url = `${baseUrl}?${params.toString()}`
109+
110+
const response = await fetch(url, {
111+
method: 'GET',
112+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
112113
})
113-
return NextResponse.json(
114-
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
115-
{ status: response.status }
116-
)
117-
}
118114

119-
const data = await response.json()
120-
for (const space of data.results || []) {
121-
spaces.push({ id: space.id, name: space.name, key: space.key })
115+
if (!response.ok) {
116+
const errorText = await response.text()
117+
throw new Error(
118+
parseAtlassianErrorMessage(response.status, response.statusText, errorText)
119+
)
120+
}
121+
122+
const data = await response.json()
123+
for (const space of data.results || []) {
124+
collected.push({ id: space.id, name: space.name, key: space.key, status })
125+
}
126+
127+
const nextLink = data._links?.next as string | undefined
128+
if (!nextLink) return { spaces: collected, capped: false }
129+
try {
130+
cursor = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
131+
} catch {
132+
cursor = undefined
133+
}
134+
if (!cursor) return { spaces: collected, capped: false }
135+
pageCount += 1
122136
}
123137

124-
const nextLink = data._links?.next as string | undefined
125-
if (!nextLink) break
126-
try {
127-
cursor = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
128-
} catch {
129-
cursor = undefined
130-
}
131-
if (!cursor) break
132-
pageCount += 1
138+
return { spaces: collected, capped: true }
139+
}
140+
141+
let currentResult: Awaited<ReturnType<typeof fetchAllPages>>
142+
let archivedResult: Awaited<ReturnType<typeof fetchAllPages>>
143+
try {
144+
;[currentResult, archivedResult] = await Promise.all([
145+
fetchAllPages('current'),
146+
fetchAllPages('archived'),
147+
])
148+
} catch (error) {
149+
logger.error('Confluence API error response', { error: (error as Error).message })
150+
return NextResponse.json({ error: (error as Error).message }, { status: 502 })
133151
}
134152

135-
if (pageCount >= MAX_PAGES) {
153+
if (currentResult.capped || archivedResult.capped) {
136154
logger.warn('Confluence space listing hit pagination cap', {
137155
cap: MAX_PAGES * PAGE_LIMIT,
138-
returned: spaces.length,
156+
currentCount: currentResult.spaces.length,
157+
archivedCount: archivedResult.spaces.length,
139158
})
140159
}
141160

161+
const spaces = [...currentResult.spaces, ...archivedResult.spaces]
142162
return NextResponse.json({ spaces })
143163
} catch (error) {
144164
logger.error('Error listing Confluence spaces:', error)

apps/sim/hooks/selectors/providers/confluence/selectors.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { fetchOAuthToken } from '@/hooks/selectors/helpers'
44
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
55
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
66

7+
function formatConfluenceSpaceLabel(space: { name: string; key: string; status?: string }): string {
8+
const base = `${space.name} (${space.key})`
9+
return space.status === 'archived' ? `${base} — archived` : base
10+
}
11+
712
export const confluenceSelectors = {
813
'confluence.spaces': {
914
key: 'confluence.spaces',
@@ -29,7 +34,7 @@ export const confluenceSelectors = {
2934
})
3035
return (data.spaces || []).map((space) => ({
3136
id: space.id,
32-
label: `${space.name} (${space.key})`,
37+
label: formatConfluenceSpaceLabel(space),
3338
}))
3439
},
3540
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
@@ -46,7 +51,7 @@ export const confluenceSelectors = {
4651
})
4752
const space = (data.spaces || []).find((s) => s.id === detailId) ?? null
4853
if (!space) return null
49-
return { id: space.id, label: `${space.name} (${space.key})` }
54+
return { id: space.id, label: formatConfluenceSpaceLabel(space) }
5055
},
5156
},
5257
'confluence.pages': {

apps/sim/lib/api/contracts/selectors/confluence.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1010
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
1111

1212
const confluenceSpaceSchema = z
13-
.object({ id: z.string(), name: z.string(), key: z.string() })
13+
.object({
14+
id: z.string(),
15+
name: z.string(),
16+
key: z.string(),
17+
status: z.string().optional(),
18+
})
1419
.passthrough()
1520

1621
export const confluencePagesBodySchema = z.object({

0 commit comments

Comments
 (0)