Skip to content

Commit 53388e8

Browse files
authored
Merge pull request #2299 from trycompai/main
[comp] Production Deploy
2 parents 6e2c15f + 8c84f19 commit 53388e8

9 files changed

Lines changed: 357 additions & 59 deletions

File tree

apps/api/src/integration-platform/controllers/sync.controller.ts

Lines changed: 131 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import { db } from '@db';
1818
import { ConnectionRepository } from '../repositories/connection.repository';
1919
import { CredentialVaultService } from '../services/credential-vault.service';
2020
import { 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

2329
interface 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-
5747
type GoogleWorkspaceSyncFilterMode = 'all' | 'exclude' | 'include';
5848

5949
const 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'

apps/app/src/test-utils/mocks/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export const createMockMember = (overrides?: Partial<Member>): Member => ({
7474
fleetDmLabelId: null,
7575
jobTitle: null,
7676
deactivated: false,
77+
externalUserId: null,
78+
externalUserSource: null,
7779
...overrides,
7880
});
7981

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "Member" ADD COLUMN "externalUserId" TEXT,
3+
ADD COLUMN "externalUserSource" TEXT;

packages/db/prisma/schema/auth.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ model Member {
102102
jobTitle String?
103103
isActive Boolean @default(true)
104104
deactivated Boolean @default(false)
105+
externalUserId String?
106+
externalUserSource String?
105107
employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]
106108
fleetDmLabelId Int?
107109

packages/integration-platform/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ export type {
120120
// Individual manifests (for direct import if needed)
121121
export { manifest as githubManifest } from './manifests/github';
122122

123+
// Ramp types (used by sync controller)
124+
export type {
125+
RampUser,
126+
RampUserStatus,
127+
RampUserRole,
128+
RampEmployee,
129+
RampUsersResponse,
130+
} from './manifests/ramp/types';
131+
123132
// API Response types (for frontend and API type sharing)
124133
export type {
125134
CheckRunFindingResponse,

0 commit comments

Comments
 (0)