From 3f8cb4b7b8717f0e3d63458b1c4818155a6603ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:43:23 -0400 Subject: [PATCH 1/2] feat(integration-platform): enhance sync controller with Ramp user integration and external user ID support (#2298) - Added externalUserId and externalUserSource fields to Member model and database schema. - Updated SyncController to utilize new RampUser and RampUserStatus types. - Implemented retry logic for fetching Ramp users to handle API rate limits and errors. - Introduced employeeSyncCheck for auditing and syncing users from Ramp. - Filtered out non-syncable user statuses during synchronization process. Co-authored-by: Tofik Hasanov Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> --- .../controllers/sync.controller.ts | 187 ++++++++++++------ apps/app/src/test-utils/mocks/auth.ts | 2 + .../migration.sql | 3 + packages/db/prisma/schema/auth.prisma | 2 + packages/integration-platform/src/index.ts | 9 + .../manifests/ramp/checks/employee-sync.ts | 163 +++++++++++++++ .../src/manifests/ramp/checks/index.ts | 1 + .../src/manifests/ramp/index.ts | 5 +- .../src/manifests/ramp/types.ts | 44 ++++- 9 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 packages/db/prisma/migrations/20260313180824_add_member_external_user_id/migration.sql create mode 100644 packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts create mode 100644 packages/integration-platform/src/manifests/ramp/checks/index.ts diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index ee471ab11c..3318c8a225 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -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; @@ -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 = @@ -363,7 +353,7 @@ export class SyncController { | 'reactivated' | 'error'; reason?: string; - rampStatus?: RampUser['status'] | 'USER_MISSING'; + rampStatus?: RampUserStatus | 'USER_MISSING'; }>, }; @@ -825,7 +815,7 @@ export class SyncController { | 'reactivated' | 'error'; reason?: string; - rampStatus?: RampUser['status'] | 'USER_MISSING'; + rampStatus?: RampUserStatus | 'USER_MISSING'; }>, }; @@ -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; @@ -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) { @@ -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([ + '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)`, + ); + } + const activeUsers = users.filter((u) => u.status === 'USER_ACTIVE'); const inactiveUsers = users.filter((u) => u.status === 'USER_INACTIVE'); @@ -1189,7 +1223,7 @@ export class SyncController { | 'reactivated' | 'error'; reason?: string; - rampStatus?: RampUser['status'] | 'USER_MISSING'; + rampStatus?: RampUserStatus | 'USER_MISSING'; }>, }; @@ -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 }, @@ -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, }, }); @@ -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; + } + 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' diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index e275f516cc..f7522cee31 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -74,6 +74,8 @@ export const createMockMember = (overrides?: Partial): Member => ({ fleetDmLabelId: null, jobTitle: null, deactivated: false, + externalUserId: null, + externalUserSource: null, ...overrides, }); diff --git a/packages/db/prisma/migrations/20260313180824_add_member_external_user_id/migration.sql b/packages/db/prisma/migrations/20260313180824_add_member_external_user_id/migration.sql new file mode 100644 index 0000000000..c78e867644 --- /dev/null +++ b/packages/db/prisma/migrations/20260313180824_add_member_external_user_id/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "externalUserId" TEXT, +ADD COLUMN "externalUserSource" TEXT; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 4a5f327546..41917a08b4 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -102,6 +102,8 @@ model Member { jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) + externalUserId String? + externalUserSource String? employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] fleetDmLabelId Int? diff --git a/packages/integration-platform/src/index.ts b/packages/integration-platform/src/index.ts index 1fc5b654ee..105997e4cf 100644 --- a/packages/integration-platform/src/index.ts +++ b/packages/integration-platform/src/index.ts @@ -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, diff --git a/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts b/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts new file mode 100644 index 0000000000..6e037b556e --- /dev/null +++ b/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts @@ -0,0 +1,163 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import type { + RampUser, + RampEmployee, + RampUserStatus, + RampUsersResponse, +} from '../types'; + +const getUserStatus = ( + user: RampUser, +): RampEmployee['status'] => { + switch (user.status) { + case 'USER_ACTIVE': + return 'active'; + case 'USER_INACTIVE': + return 'inactive'; + case 'USER_SUSPENDED': + return 'suspended'; + case 'USER_ONBOARDING': + return 'onboarding'; + case 'INVITE_PENDING': + return 'invite_pending'; + case 'INVITE_EXPIRED': + return 'invite_expired'; + default: + return 'inactive'; + } +}; + +const getFullName = (user: RampUser): string => { + const parts = [user.first_name, user.last_name].filter(Boolean); + if (parts.length > 0) return parts.join(' '); + return user.email?.split('@')[0] ?? 'Unknown'; +}; + +/** + * Employee Sync Check + * + * Fetches all users from Ramp and provides evidence for audit trail. + * This gives auditors a complete snapshot of the employee roster. + */ +export const employeeSyncCheck: IntegrationCheck = { + id: 'employee-sync', + name: 'Employee Sync', + description: + 'Sync users from Ramp as employees for access review and verification', + taskMapping: TASK_TEMPLATES.employeeAccess, + defaultSeverity: 'info', + + run: async (ctx: CheckContext) => { + ctx.log('Starting Ramp Employee Sync'); + + const allUsers: RampUser[] = []; + + // Fetch active + inactive users (default behavior) + ctx.log('Fetching users from Ramp...'); + + const fetchAllRampUsers = async ( + initialPath: string, + ): Promise => { + const result: RampUser[] = []; + let currentUrl: string | null = initialPath; + let isFirst = true; + + while (currentUrl) { + const response: RampUsersResponse = await ctx.fetch( + currentUrl, + isFirst ? { baseUrl: 'https://api.ramp.com' } : undefined, + ); + isFirst = false; + + if (response.data?.length) { + result.push(...response.data); + } + + currentUrl = response.page?.next ?? null; + } + + return result; + }; + + const baseUsers = await fetchAllRampUsers( + '/developer/v1/users?page_size=100', + ); + allUsers.push(...baseUsers); + ctx.log(`Fetched ${baseUsers.length} active/inactive users`); + + // Also fetch suspended users (not included by default) + const suspendedUsers = await fetchAllRampUsers( + '/developer/v1/users?page_size=100&status=USER_SUSPENDED', + ); + allUsers.push(...suspendedUsers); + ctx.log(`Fetched ${suspendedUsers.length} suspended users`); + + ctx.log(`Fetched ${allUsers.length} total users from Ramp`); + + // Filter out non-syncable statuses + const syncableStatuses = new Set([ + 'USER_ACTIVE', + 'USER_INACTIVE', + 'USER_SUSPENDED', + ]); + const syncableUsers = allUsers.filter( + (u) => u.status && syncableStatuses.has(u.status), + ); + + // Transform to employee format + const employees: RampEmployee[] = syncableUsers.map((user) => ({ + id: user.id, + email: user.email, + name: getFullName(user), + firstName: user.first_name, + lastName: user.last_name, + employeeId: user.employee_id, + status: getUserStatus(user), + role: user.role, + departmentId: user.department_id, + locationId: user.location_id, + managerId: user.manager_id, + phone: user.phone, + isManager: user.is_manager, + })); + + // Calculate statistics + const activeEmployees = employees.filter((e) => e.status === 'active'); + const inactiveEmployees = employees.filter((e) => e.status === 'inactive'); + const suspendedEmployees = employees.filter( + (e) => e.status === 'suspended', + ); + const managers = employees.filter((e) => e.isManager); + + // Group by role for summary + const roleCounts = new Map(); + for (const emp of employees) { + const role = emp.role || 'Unknown'; + roleCounts.set(role, (roleCounts.get(role) || 0) + 1); + } + + const roleSummary = Array.from(roleCounts.entries()) + .map(([role, count]) => ({ role, count })) + .sort((a, b) => b.count - a.count); + + ctx.pass({ + title: 'Ramp Employee List', + resourceType: 'organization', + resourceId: 'ramp', + description: `Retrieved ${employees.length} employees from Ramp (${activeEmployees.length} active, ${inactiveEmployees.length} inactive, ${suspendedEmployees.length} suspended, ${managers.length} managers)`, + evidence: { + totalUsers: employees.length, + activeCount: activeEmployees.length, + inactiveCount: inactiveEmployees.length, + suspendedCount: suspendedEmployees.length, + managerCount: managers.length, + roleSummary, + reviewedAt: new Date().toISOString(), + employees, + }, + }); + + ctx.log('Ramp Employee Sync complete'); + }, +}; diff --git a/packages/integration-platform/src/manifests/ramp/checks/index.ts b/packages/integration-platform/src/manifests/ramp/checks/index.ts new file mode 100644 index 0000000000..890c2d48fc --- /dev/null +++ b/packages/integration-platform/src/manifests/ramp/checks/index.ts @@ -0,0 +1 @@ +export { employeeSyncCheck } from './employee-sync'; diff --git a/packages/integration-platform/src/manifests/ramp/index.ts b/packages/integration-platform/src/manifests/ramp/index.ts index b52b343310..0d51731208 100644 --- a/packages/integration-platform/src/manifests/ramp/index.ts +++ b/packages/integration-platform/src/manifests/ramp/index.ts @@ -6,6 +6,7 @@ */ import type { IntegrationManifest } from '../../types'; +import { employeeSyncCheck } from './checks'; export const rampManifest: IntegrationManifest = { id: 'ramp', @@ -46,8 +47,8 @@ export const rampManifest: IntegrationManifest = { Accept: 'application/json', }, - capabilities: ['sync'], - checks: [], + capabilities: ['sync', 'checks'], + checks: [employeeSyncCheck], }; export default rampManifest; diff --git a/packages/integration-platform/src/manifests/ramp/types.ts b/packages/integration-platform/src/manifests/ramp/types.ts index e429b71647..347fbf8c29 100644 --- a/packages/integration-platform/src/manifests/ramp/types.ts +++ b/packages/integration-platform/src/manifests/ramp/types.ts @@ -1,10 +1,52 @@ +export type RampUserStatus = + | 'USER_ACTIVE' + | 'USER_INACTIVE' + | 'USER_SUSPENDED' + | 'INVITE_PENDING' + | 'INVITE_EXPIRED' + | 'USER_ONBOARDING'; + +export type RampUserRole = + | 'AUDITOR' + | 'BUSINESS_ADMIN' + | 'BUSINESS_BOOKKEEPER' + | 'BUSINESS_OWNER' + | 'BUSINESS_USER' + | 'GUEST_USER' + | 'IT_ADMIN'; + export interface RampUser { id: string; email: string; first_name?: string; last_name?: string; employee_id?: string | null; - status?: 'USER_ACTIVE' | 'USER_INACTIVE' | 'USER_SUSPENDED'; + status?: RampUserStatus; + role?: RampUserRole; + department_id?: string; + location_id?: string; + manager_id?: string; + phone?: string; + is_manager?: boolean; + business_id?: string; + entity_id?: string; + scheduled_deactivation_date?: string; +} + +export interface RampEmployee { + id: string; + email: string; + name: string; + firstName?: string; + lastName?: string; + employeeId?: string | null; + status: 'active' | 'inactive' | 'suspended' | 'onboarding' | 'invite_pending' | 'invite_expired'; + role?: RampUserRole; + departmentId?: string; + locationId?: string; + managerId?: string; + phone?: string; + isManager?: boolean; } export interface RampPage { From 7d12e5693bbee42e1b9913e888d31077ac35112e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:36:35 -0400 Subject: [PATCH 2/2] feat(training): update download training certificate action to forward session cookies for authentication (#2300) Co-authored-by: Tofik Hasanov --- .../actions/download-training-certificate.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts index b316711db9..86f616452b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts @@ -2,6 +2,7 @@ import { authActionClient } from '@/actions/safe-action'; import { env } from '@/env.mjs'; +import { headers } from 'next/headers'; import { z } from 'zod'; const downloadCertificateSchema = z.object({ @@ -32,23 +33,26 @@ export const downloadTrainingCertificate = authActionClient ); } - // Call the API to generate the certificate const apiUrl = env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; - const internalToken = env.INTERNAL_API_TOKEN; - if (!internalToken) { - throw new Error('INTERNAL_API_TOKEN not configured'); + // Forward session cookies for authentication + const headerStore = await headers(); + const cookieHeader = headerStore.get('cookie'); + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + }; + + if (cookieHeader) { + requestHeaders['Cookie'] = cookieHeader; } const response = await fetch(`${apiUrl}/v1/training/generate-certificate`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-internal-token': internalToken, - }, + headers: requestHeaders, body: JSON.stringify({ memberId, organizationId,