Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 131 additions & 56 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import { db } from '@db';
import { ConnectionRepository } from '../repositories/connection.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { getManifest, type OAuthConfig } from '@comp/integration-platform';
import {
getManifest,
type OAuthConfig,
type RampUser,
type RampUserStatus,
type RampUsersResponse,
} from '@comp/integration-platform';

interface GoogleWorkspaceUser {
id: string;
Expand All @@ -38,22 +44,6 @@ interface GoogleWorkspaceUsersResponse {
nextPageToken?: string;
}

interface RampUser {
id: string;
email: string;
first_name?: string;
last_name?: string;
employee_id?: string | null;
status?: 'USER_ACTIVE' | 'USER_INACTIVE' | 'USER_SUSPENDED';
}

interface RampUsersResponse {
data: RampUser[];
page: {
next?: string | null;
};
}

type GoogleWorkspaceSyncFilterMode = 'all' | 'exclude' | 'include';

const GOOGLE_WORKSPACE_SYNC_FILTER_MODES =
Expand Down Expand Up @@ -363,7 +353,7 @@ export class SyncController {
| 'reactivated'
| 'error';
reason?: string;
rampStatus?: RampUser['status'] | 'USER_MISSING';
rampStatus?: RampUserStatus | 'USER_MISSING';
}>,
};

Expand Down Expand Up @@ -825,7 +815,7 @@ export class SyncController {
| 'reactivated'
| 'error';
reason?: string;
rampStatus?: RampUser['status'] | 'USER_MISSING';
rampStatus?: RampUserStatus | 'USER_MISSING';
}>,
};

Expand Down Expand Up @@ -1081,7 +1071,9 @@ export class SyncController {
);
}

const fetchRampUsers = async (status?: RampUser['status']) => {
const MAX_RETRIES = 3;

const fetchRampUsers = async (status?: RampUserStatus) => {
const users: RampUser[] = [];
let nextUrl: string | null = null;

Expand All @@ -1097,12 +1089,39 @@ export class SyncController {
}
}

const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
let response: Response | null = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});

if (
response.status === 429 ||
(response.status >= 500 && response.status < 600)
) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.min(1000 * 2 ** attempt, 30000);
this.logger.warn(
`Ramp API returned ${response.status}, retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
);
await new Promise((r) => setTimeout(r, delay));
continue;
}

break;
}

if (!response) {
throw new HttpException(
'Failed to fetch users from Ramp',
HttpStatus.BAD_GATEWAY,
);
}

if (!response.ok) {
if (response.status === 401) {
Expand Down Expand Up @@ -1151,6 +1170,21 @@ export class SyncController {
const suspendedUsers = await fetchRampUsers('USER_SUSPENDED');
const users = [...baseUsers, ...suspendedUsers];

// Filter out non-syncable statuses (pending invites, onboarding, expired)
const syncableStatuses = new Set<RampUserStatus>([
'USER_ACTIVE',
'USER_INACTIVE',
'USER_SUSPENDED',
]);
const skippedStatuses = users.filter(
(u) => u.status && !syncableStatuses.has(u.status),
);
if (skippedStatuses.length > 0) {
this.logger.log(
`Skipping ${skippedStatuses.length} Ramp users with non-syncable statuses (INVITE_PENDING, INVITE_EXPIRED, USER_ONBOARDING)`,
);
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-syncable users logged but never filtered out

High Severity

The code at lines 1173–1186 identifies Ramp users with non-syncable statuses (INVITE_PENDING, INVITE_EXPIRED, USER_ONBOARDING) and logs "Skipping" them, but never actually filters the users array. The unfiltered users feeds into rampDomains (line 1354), so non-syncable users' emails end up in no email set (activeEmails, inactiveEmails, suspendedEmails), causing isRemoved to be true for matching org members — incorrectly deactivating them. The companion employee-sync.ts check correctly filters with allUsers.filter(...) into a new syncableUsers array.

Additional Locations (2)
Fix in Cursor Fix in Web

const activeUsers = users.filter((u) => u.status === 'USER_ACTIVE');
const inactiveUsers = users.filter((u) => u.status === 'USER_INACTIVE');

Expand Down Expand Up @@ -1189,7 +1223,7 @@ export class SyncController {
| 'reactivated'
| 'error';
reason?: string;
rampStatus?: RampUser['status'] | 'USER_MISSING';
rampStatus?: RampUserStatus | 'USER_MISSING';
}>,
};

Expand All @@ -1200,37 +1234,45 @@ export class SyncController {
}

try {
const existingUser = await db.user.findUnique({
where: { email: normalizedEmail },
});

let userId: string;

if (existingUser) {
userId = existingUser.id;
} else {
const displayName =
`${rampUser.first_name ?? ''} ${rampUser.last_name ?? ''}`.trim() ||
normalizedEmail.split('@')[0];

const newUser = await db.user.create({
data: {
email: normalizedEmail,
name: displayName,
emailVerified: true,
},
// Try external ID match first (handles email changes)
let existingMember = rampUser.id
? await db.member.findFirst({
where: {
organizationId,
externalUserId: rampUser.id,
externalUserSource: 'ramp',
},
})
: null;

// Fall back to email match
if (!existingMember) {
const existingUser = await db.user.findUnique({
where: { email: normalizedEmail },
});
userId = newUser.id;
if (existingUser) {
existingMember = await db.member.findFirst({
where: { organizationId, userId: existingUser.id },
});
}
}

const existingMember = await db.member.findFirst({
where: {
organizationId,
userId,
},
});

if (existingMember) {
// Backfill external ID if not set
if (
rampUser.id &&
(!existingMember.externalUserId ||
existingMember.externalUserSource !== 'ramp')
) {
await db.member.update({
where: { id: existingMember.id },
data: {
externalUserId: rampUser.id,
externalUserSource: 'ramp',
},
});
}

if (existingMember.deactivated) {
await db.member.update({
where: { id: existingMember.id },
Expand All @@ -1253,12 +1295,33 @@ export class SyncController {
continue;
}

// Create new user if needed
let existingUser = await db.user.findUnique({
where: { email: normalizedEmail },
});

if (!existingUser) {
const displayName =
`${rampUser.first_name ?? ''} ${rampUser.last_name ?? ''}`.trim() ||
normalizedEmail.split('@')[0];

existingUser = await db.user.create({
data: {
email: normalizedEmail,
name: displayName,
emailVerified: true,
},
});
}

await db.member.create({
data: {
organizationId,
userId,
userId: existingUser.id,
role: 'employee',
isActive: true,
externalUserId: rampUser.id || null,
externalUserSource: rampUser.id ? 'ramp' : null,
},
});

Expand Down Expand Up @@ -1302,11 +1365,23 @@ export class SyncController {
continue;
}

// Safety guard: never auto-deactivate privileged members via sync
const memberRoles = member.role
.split(',')
.map((r) => r.trim().toLowerCase());
if (
memberRoles.includes('owner') ||
memberRoles.includes('admin') ||
memberRoles.includes('auditor')
) {
continue;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deactivation undoes external-ID-based email change handling

High Severity

The import phase matches members by externalUserId to "handle email changes" (per the comment), but the deactivation phase only matches by email. If a Ramp user changed their email, the import phase correctly finds the member via external ID and skips it as "Already a member" — without updating the user's email on the user record. Then the deactivation loop checks the member's old email against activeEmails, doesn't find it, and deactivates the member as USER_MISSING. This undoes the external ID matching from moments earlier, making the email-change handling counterproductive — a regression from the pre-PR behavior where at least a new member with the new email would have been created.

Additional Locations (1)
Fix in Cursor Fix in Web

const isSuspended = suspendedEmails.has(memberEmail);
const isInactive = inactiveEmails.has(memberEmail);
const isRemoved =
!activeEmails.has(memberEmail) && !isSuspended && !isInactive;
const rampStatus: RampUser['status'] | 'USER_MISSING' = isSuspended
const rampStatus: RampUserStatus | 'USER_MISSING' = isSuspended
? 'USER_SUSPENDED'
: isInactive
? 'USER_INACTIVE'
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/test-utils/mocks/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const createMockMember = (overrides?: Partial<Member>): Member => ({
fleetDmLabelId: null,
jobTitle: null,
deactivated: false,
externalUserId: null,
externalUserSource: null,
...overrides,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Member" ADD COLUMN "externalUserId" TEXT,
ADD COLUMN "externalUserSource" TEXT;
2 changes: 2 additions & 0 deletions packages/db/prisma/schema/auth.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ model Member {
jobTitle String?
isActive Boolean @default(true)
deactivated Boolean @default(false)
externalUserId String?
externalUserSource String?
employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]
fleetDmLabelId Int?

Expand Down
9 changes: 9 additions & 0 deletions packages/integration-platform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ export type {
// Individual manifests (for direct import if needed)
export { manifest as githubManifest } from './manifests/github';

// Ramp types (used by sync controller)
export type {
RampUser,
RampUserStatus,
RampUserRole,
RampEmployee,
RampUsersResponse,
} from './manifests/ramp/types';

// API Response types (for frontend and API type sharing)
export type {
CheckRunFindingResponse,
Expand Down
Loading
Loading