@@ -19,13 +19,33 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI')
1919
2020export const dynamic = 'force-dynamic'
2121
22+ const PAGE_LIMIT = 250
23+
24+ type SpaceStatus = 'current' | 'archived'
25+
26+ /**
27+ * Cursor format: `<status>:<innerCursor>`. Empty inner cursor means "first page
28+ * of that status". When current is exhausted we hand back `archived:` so the
29+ * client transparently flips to the archived stream — listing both surfaces
30+ * archived spaces in the dropdown, which would otherwise only be reachable by
31+ * typing the space key manually even though sync works against archived spaces.
32+ */
33+ function parseCursor ( raw : string | undefined ) : { status : SpaceStatus ; inner ?: string } {
34+ if ( ! raw ) return { status : 'current' }
35+ const idx = raw . indexOf ( ':' )
36+ if ( idx === - 1 ) return { status : 'current' }
37+ const status = raw . slice ( 0 , idx ) === 'archived' ? 'archived' : 'current'
38+ const inner = raw . slice ( idx + 1 )
39+ return { status, inner : inner || undefined }
40+ }
41+
2242export const POST = withRouteHandler ( async ( request : NextRequest ) => {
2343 const requestId = generateRequestId ( )
2444 try {
2545 const parsed = await parseRequest ( confluenceSpacesSelectorContract , request , { } )
2646 if ( ! parsed . success ) return parsed . response
2747
28- const { credential, workflowId, domain } = parsed . data . body
48+ const { credential, workflowId, domain, cursor } = parsed . data . body
2949
3050 if ( ! credential ) {
3151 logger . error ( 'Missing credential in request' )
@@ -44,11 +64,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4464 return NextResponse . json ( { error : authz . error || 'Unauthorized' } , { status : 403 } )
4565 }
4666
47- // Resolve once so we know whether this is an Atlassian SA credential before
48- // doing any token / cloudId work. Atlassian SAs short-circuit the entire path:
49- // the API token IS the access token, and cloudId lives in the encrypted secret —
50- // so we skip refreshAccessTokenIfNeeded (avoids a redundant resolve+decrypt) and
51- // skip getConfluenceCloudId (which 401s for scoped SA tokens).
5267 const resolved = await resolveOAuthAccountId ( credential )
5368 const isAtlassianServiceAccount =
5469 resolved ?. providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && ! ! resolved . credentialId
@@ -84,82 +99,50 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8499 }
85100
86101 const baseUrl = `https://api.atlassian.com/ex/confluence/${ cloudIdValidation . sanitized } /wiki/api/v2/spaces`
87- const PAGE_LIMIT = 250
88- const MAX_PAGES = 20
89-
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 } ` } ,
113- } )
102+ const { status, inner } = parseCursor ( cursor )
114103
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
136- }
104+ const params = new URLSearchParams ( { limit : String ( PAGE_LIMIT ) , status } )
105+ if ( inner ) params . set ( 'cursor' , inner )
106+ const url = `${ baseUrl } ?${ params . toString ( ) } `
137107
138- return { spaces : collected , capped : true }
108+ const response = await fetch ( url , {
109+ method : 'GET' ,
110+ headers : { Accept : 'application/json' , Authorization : `Bearer ${ accessToken } ` } ,
111+ } )
112+
113+ if ( ! response . ok ) {
114+ const errorText = await response . text ( )
115+ const message = parseAtlassianErrorMessage ( response . status , response . statusText , errorText )
116+ logger . error ( 'Confluence API error response' , { error : message , status : response . status } )
117+ return NextResponse . json ( { error : message } , { status : 502 } )
139118 }
140119
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 } )
120+ const data = await response . json ( )
121+ const spaces = ( data . results || [ ] ) . map ( ( space : { id : string ; name : string ; key : string } ) => ( {
122+ id : space . id ,
123+ name : space . name ,
124+ key : space . key ,
125+ status,
126+ } ) )
127+
128+ let nextInner : string | undefined
129+ const nextLink = data . _links ?. next as string | undefined
130+ if ( nextLink ) {
131+ try {
132+ nextInner = new URL ( nextLink , 'https://placeholder' ) . searchParams . get ( 'cursor' ) || undefined
133+ } catch {
134+ nextInner = undefined
135+ }
151136 }
152137
153- if ( currentResult . capped || archivedResult . capped ) {
154- logger . warn ( 'Confluence space listing hit pagination cap' , {
155- cap : MAX_PAGES * PAGE_LIMIT ,
156- currentCount : currentResult . spaces . length ,
157- archivedCount : archivedResult . spaces . length ,
158- } )
138+ let nextCursor : string | undefined
139+ if ( nextInner ) {
140+ nextCursor = `${ status } :${ nextInner } `
141+ } else if ( status === 'current' ) {
142+ nextCursor = 'archived:'
159143 }
160144
161- const spaces = [ ...currentResult . spaces , ...archivedResult . spaces ]
162- return NextResponse . json ( { spaces } )
145+ return NextResponse . json ( { spaces, nextCursor } )
163146 } catch ( error ) {
164147 logger . error ( 'Error listing Confluence spaces:' , error )
165148 return NextResponse . json (
0 commit comments