@@ -18,7 +18,13 @@ import { db } from '@db';
1818import { ConnectionRepository } from '../repositories/connection.repository' ;
1919import { CredentialVaultService } from '../services/credential-vault.service' ;
2020import { OAuthCredentialsService } from '../services/oauth-credentials.service' ;
21- import { getManifest , type OAuthConfig } from '@comp/integration-platform' ;
21+ import {
22+ getManifest ,
23+ type OAuthConfig ,
24+ type RampUser ,
25+ type RampUserStatus ,
26+ type RampUsersResponse ,
27+ } from '@comp/integration-platform' ;
2228
2329interface GoogleWorkspaceUser {
2430 id : string ;
@@ -38,22 +44,6 @@ interface GoogleWorkspaceUsersResponse {
3844 nextPageToken ?: string ;
3945}
4046
41- interface RampUser {
42- id : string ;
43- email : string ;
44- first_name ?: string ;
45- last_name ?: string ;
46- employee_id ?: string | null ;
47- status ?: 'USER_ACTIVE' | 'USER_INACTIVE' | 'USER_SUSPENDED' ;
48- }
49-
50- interface RampUsersResponse {
51- data : RampUser [ ] ;
52- page : {
53- next ?: string | null ;
54- } ;
55- }
56-
5747type GoogleWorkspaceSyncFilterMode = 'all' | 'exclude' | 'include' ;
5848
5949const GOOGLE_WORKSPACE_SYNC_FILTER_MODES =
@@ -363,7 +353,7 @@ export class SyncController {
363353 | 'reactivated'
364354 | 'error' ;
365355 reason ?: string ;
366- rampStatus ?: RampUser [ 'status' ] | 'USER_MISSING' ;
356+ rampStatus ?: RampUserStatus | 'USER_MISSING' ;
367357 } > ,
368358 } ;
369359
@@ -825,7 +815,7 @@ export class SyncController {
825815 | 'reactivated'
826816 | 'error' ;
827817 reason ?: string ;
828- rampStatus ?: RampUser [ 'status' ] | 'USER_MISSING' ;
818+ rampStatus ?: RampUserStatus | 'USER_MISSING' ;
829819 } > ,
830820 } ;
831821
@@ -1081,7 +1071,9 @@ export class SyncController {
10811071 ) ;
10821072 }
10831073
1084- const fetchRampUsers = async ( status ?: RampUser [ 'status' ] ) => {
1074+ const MAX_RETRIES = 3 ;
1075+
1076+ const fetchRampUsers = async ( status ?: RampUserStatus ) => {
10851077 const users : RampUser [ ] = [ ] ;
10861078 let nextUrl : string | null = null ;
10871079
@@ -1097,12 +1089,39 @@ export class SyncController {
10971089 }
10981090 }
10991091
1100- const response = await fetch ( url . toString ( ) , {
1101- headers : {
1102- Authorization : `Bearer ${ accessToken } ` ,
1103- 'Content-Type' : 'application/json' ,
1104- } ,
1105- } ) ;
1092+ let response : Response | null = null ;
1093+ for ( let attempt = 0 ; attempt < MAX_RETRIES ; attempt ++ ) {
1094+ response = await fetch ( url . toString ( ) , {
1095+ headers : {
1096+ Authorization : `Bearer ${ accessToken } ` ,
1097+ 'Content-Type' : 'application/json' ,
1098+ } ,
1099+ } ) ;
1100+
1101+ if (
1102+ response . status === 429 ||
1103+ ( response . status >= 500 && response . status < 600 )
1104+ ) {
1105+ const retryAfter = response . headers . get ( 'Retry-After' ) ;
1106+ const delay = retryAfter
1107+ ? parseInt ( retryAfter , 10 ) * 1000
1108+ : Math . min ( 1000 * 2 ** attempt , 30000 ) ;
1109+ this . logger . warn (
1110+ `Ramp API returned ${ response . status } , retrying in ${ delay } ms (attempt ${ attempt + 1 } /${ MAX_RETRIES } )` ,
1111+ ) ;
1112+ await new Promise ( ( r ) => setTimeout ( r , delay ) ) ;
1113+ continue ;
1114+ }
1115+
1116+ break ;
1117+ }
1118+
1119+ if ( ! response ) {
1120+ throw new HttpException (
1121+ 'Failed to fetch users from Ramp' ,
1122+ HttpStatus . BAD_GATEWAY ,
1123+ ) ;
1124+ }
11061125
11071126 if ( ! response . ok ) {
11081127 if ( response . status === 401 ) {
@@ -1151,6 +1170,21 @@ export class SyncController {
11511170 const suspendedUsers = await fetchRampUsers ( 'USER_SUSPENDED' ) ;
11521171 const users = [ ...baseUsers , ...suspendedUsers ] ;
11531172
1173+ // Filter out non-syncable statuses (pending invites, onboarding, expired)
1174+ const syncableStatuses = new Set < RampUserStatus > ( [
1175+ 'USER_ACTIVE' ,
1176+ 'USER_INACTIVE' ,
1177+ 'USER_SUSPENDED' ,
1178+ ] ) ;
1179+ const skippedStatuses = users . filter (
1180+ ( u ) => u . status && ! syncableStatuses . has ( u . status ) ,
1181+ ) ;
1182+ if ( skippedStatuses . length > 0 ) {
1183+ this . logger . log (
1184+ `Skipping ${ skippedStatuses . length } Ramp users with non-syncable statuses (INVITE_PENDING, INVITE_EXPIRED, USER_ONBOARDING)` ,
1185+ ) ;
1186+ }
1187+
11541188 const activeUsers = users . filter ( ( u ) => u . status === 'USER_ACTIVE' ) ;
11551189 const inactiveUsers = users . filter ( ( u ) => u . status === 'USER_INACTIVE' ) ;
11561190
@@ -1189,7 +1223,7 @@ export class SyncController {
11891223 | 'reactivated'
11901224 | 'error' ;
11911225 reason ?: string ;
1192- rampStatus ?: RampUser [ 'status' ] | 'USER_MISSING' ;
1226+ rampStatus ?: RampUserStatus | 'USER_MISSING' ;
11931227 } > ,
11941228 } ;
11951229
@@ -1200,37 +1234,45 @@ export class SyncController {
12001234 }
12011235
12021236 try {
1203- const existingUser = await db . user . findUnique ( {
1204- where : { email : normalizedEmail } ,
1205- } ) ;
1206-
1207- let userId : string ;
1208-
1209- if ( existingUser ) {
1210- userId = existingUser . id ;
1211- } else {
1212- const displayName =
1213- `${ rampUser . first_name ?? '' } ${ rampUser . last_name ?? '' } ` . trim ( ) ||
1214- normalizedEmail . split ( '@' ) [ 0 ] ;
1215-
1216- const newUser = await db . user . create ( {
1217- data : {
1218- email : normalizedEmail ,
1219- name : displayName ,
1220- emailVerified : true ,
1221- } ,
1237+ // Try external ID match first (handles email changes)
1238+ let existingMember = rampUser . id
1239+ ? await db . member . findFirst ( {
1240+ where : {
1241+ organizationId,
1242+ externalUserId : rampUser . id ,
1243+ externalUserSource : 'ramp' ,
1244+ } ,
1245+ } )
1246+ : null ;
1247+
1248+ // Fall back to email match
1249+ if ( ! existingMember ) {
1250+ const existingUser = await db . user . findUnique ( {
1251+ where : { email : normalizedEmail } ,
12221252 } ) ;
1223- userId = newUser . id ;
1253+ if ( existingUser ) {
1254+ existingMember = await db . member . findFirst ( {
1255+ where : { organizationId, userId : existingUser . id } ,
1256+ } ) ;
1257+ }
12241258 }
12251259
1226- const existingMember = await db . member . findFirst ( {
1227- where : {
1228- organizationId,
1229- userId,
1230- } ,
1231- } ) ;
1232-
12331260 if ( existingMember ) {
1261+ // Backfill external ID if not set
1262+ if (
1263+ rampUser . id &&
1264+ ( ! existingMember . externalUserId ||
1265+ existingMember . externalUserSource !== 'ramp' )
1266+ ) {
1267+ await db . member . update ( {
1268+ where : { id : existingMember . id } ,
1269+ data : {
1270+ externalUserId : rampUser . id ,
1271+ externalUserSource : 'ramp' ,
1272+ } ,
1273+ } ) ;
1274+ }
1275+
12341276 if ( existingMember . deactivated ) {
12351277 await db . member . update ( {
12361278 where : { id : existingMember . id } ,
@@ -1253,12 +1295,33 @@ export class SyncController {
12531295 continue ;
12541296 }
12551297
1298+ // Create new user if needed
1299+ let existingUser = await db . user . findUnique ( {
1300+ where : { email : normalizedEmail } ,
1301+ } ) ;
1302+
1303+ if ( ! existingUser ) {
1304+ const displayName =
1305+ `${ rampUser . first_name ?? '' } ${ rampUser . last_name ?? '' } ` . trim ( ) ||
1306+ normalizedEmail . split ( '@' ) [ 0 ] ;
1307+
1308+ existingUser = await db . user . create ( {
1309+ data : {
1310+ email : normalizedEmail ,
1311+ name : displayName ,
1312+ emailVerified : true ,
1313+ } ,
1314+ } ) ;
1315+ }
1316+
12561317 await db . member . create ( {
12571318 data : {
12581319 organizationId,
1259- userId,
1320+ userId : existingUser . id ,
12601321 role : 'employee' ,
12611322 isActive : true ,
1323+ externalUserId : rampUser . id || null ,
1324+ externalUserSource : rampUser . id ? 'ramp' : null ,
12621325 } ,
12631326 } ) ;
12641327
@@ -1302,11 +1365,23 @@ export class SyncController {
13021365 continue ;
13031366 }
13041367
1368+ // Safety guard: never auto-deactivate privileged members via sync
1369+ const memberRoles = member . role
1370+ . split ( ',' )
1371+ . map ( ( r ) => r . trim ( ) . toLowerCase ( ) ) ;
1372+ if (
1373+ memberRoles . includes ( 'owner' ) ||
1374+ memberRoles . includes ( 'admin' ) ||
1375+ memberRoles . includes ( 'auditor' )
1376+ ) {
1377+ continue ;
1378+ }
1379+
13051380 const isSuspended = suspendedEmails . has ( memberEmail ) ;
13061381 const isInactive = inactiveEmails . has ( memberEmail ) ;
13071382 const isRemoved =
13081383 ! activeEmails . has ( memberEmail ) && ! isSuspended && ! isInactive ;
1309- const rampStatus : RampUser [ 'status' ] | 'USER_MISSING' = isSuspended
1384+ const rampStatus : RampUserStatus | 'USER_MISSING' = isSuspended
13101385 ? 'USER_SUSPENDED'
13111386 : isInactive
13121387 ? 'USER_INACTIVE'
0 commit comments