From ae5ad4b89059af4112e87d5e91be82b29da01815 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 12 Mar 2026 16:18:40 -0400 Subject: [PATCH 01/15] fix(app): use claude-sonnet-4.6 model --- .../automation/[automationId]/hooks/use-chat-handlers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts index 499be6287e..2ac2165716 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts @@ -74,7 +74,7 @@ export function useChatHandlers({ { text }, { body: { - modelId: 'openai/gpt-5-mini', + modelId: 'anthropic/claude-sonnet-4.6', reasoningEffort: 'medium', orgId, taskId, @@ -96,7 +96,7 @@ export function useChatHandlers({ }, { body: { - modelId: 'openai/gpt-5-mini', + modelId: 'anthropic/claude-sonnet-4.6', reasoningEffort: 'medium', orgId, taskId, @@ -120,7 +120,7 @@ export function useChatHandlers({ }, { body: { - modelId: 'openai/gpt-5-mini', + modelId: 'anthropic/claude-sonnet-4.6', reasoningEffort: 'medium', orgId, taskId, From c1194bee67d11f53a972e40167768c778eb2f9db Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 12 Mar 2026 16:24:25 -0400 Subject: [PATCH 02/15] fix(app): upgrade the model in workflow visualizer file --- .../components/workflow/workflow-visualizer-simple.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx index 40d52e89bc..9e4825de61 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx @@ -229,7 +229,7 @@ Please fix the automation script to resolve this error.`; { text: errorMessage }, { body: { - modelId: 'openai/gpt-5-mini', + modelId: 'anthropic/claude-sonnet-4.6', reasoningEffort: 'medium', orgId, taskId, From 31fc2653ced83ce47ca3aa29d43a8c756584d431 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 12 Mar 2026 22:10:47 -0400 Subject: [PATCH 03/15] fix(app): use gemini 3.1 flash lite with high reasoning --- .../workflow/workflow-visualizer-simple.tsx | 4 ++-- .../[automationId]/hooks/use-chat-handlers.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx index 9e4825de61..3826ee6e38 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/workflow/workflow-visualizer-simple.tsx @@ -229,8 +229,8 @@ Please fix the automation script to resolve this error.`; { text: errorMessage }, { body: { - modelId: 'anthropic/claude-sonnet-4.6', - reasoningEffort: 'medium', + modelId: 'google/gemini-3.1-flash-lite-preview', + reasoningEffort: 'high', orgId, taskId, automationId, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts index 2ac2165716..8b71aeda6b 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts @@ -74,8 +74,8 @@ export function useChatHandlers({ { text }, { body: { - modelId: 'anthropic/claude-sonnet-4.6', - reasoningEffort: 'medium', + modelId: 'google/gemini-3.1-flash-lite-preview', + reasoningEffort: 'high', orgId, taskId, automationId: realAutomationId, @@ -96,8 +96,8 @@ export function useChatHandlers({ }, { body: { - modelId: 'anthropic/claude-sonnet-4.6', - reasoningEffort: 'medium', + modelId: 'google/gemini-3.1-flash-lite-preview', + reasoningEffort: 'high', orgId, taskId, automationId, @@ -120,8 +120,8 @@ export function useChatHandlers({ }, { body: { - modelId: 'anthropic/claude-sonnet-4.6', - reasoningEffort: 'medium', + modelId: 'google/gemini-3.1-flash-lite-preview', + reasoningEffort: 'high', orgId, taskId, automationId, From 3cf5fe4c5f923459fd6e93187ce6251424fe7b5a Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 16 Mar 2026 14:34:14 -0400 Subject: [PATCH 04/15] feat(integration-platform): add Ramp role mapping functionality - Introduced RampRoleMappingController to handle role mapping operations. - Added RampRoleMappingService for managing role mappings and ensuring custom roles exist. - Updated SyncController to integrate role mapping logic during employee sync. - Enhanced UI components to support role mapping configuration and display. - Implemented role mapping data fetching and saving in the employee sync process. --- .../ramp-role-mapping.controller.ts | 237 ++++++++++++++++++ .../controllers/sync.controller.ts | 177 ++++++++++++- .../integration-platform.module.ts | 4 + .../services/ramp-role-mapping.service.ts | 217 ++++++++++++++++ .../all/components/RampRoleMappingSheet.tsx | 147 +++++++++++ .../all/components/TeamMembersClient.tsx | 88 ++++--- .../people/all/hooks/useEmployeeSync.ts | 120 +++++++++ .../people/components/PeoplePageTabs.tsx | 8 + .../app/src/app/(app)/[orgId]/people/page.tsx | 18 +- .../components/RampRoleMappingContent.tsx | 184 ++++++++++++++ .../components/RampRoleMappingRow.tsx | 233 +++++++++++++++++ .../components/RoleMappingTab.tsx | 69 +++++ apps/app/tsconfig.json | 6 - apps/portal/tsconfig.json | 12 +- packages/docs/openapi.json | 81 ++++++ packages/integration-platform/src/index.ts | 2 + .../manifests/ramp/checks/employee-sync.ts | 2 +- .../src/manifests/ramp/index.ts | 8 +- .../src/manifests/ramp/types.ts | 13 +- 19 files changed, 1556 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts create mode 100644 apps/api/src/integration-platform/services/ramp-role-mapping.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/RampRoleMappingSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RoleMappingTab.tsx diff --git a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts new file mode 100644 index 0000000000..20b6f70f39 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -0,0 +1,237 @@ +import { + Controller, + Post, + Get, + Query, + Body, + HttpException, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { RequirePermission } from '../../auth/require-permission.decorator'; +import { OrganizationId } from '../../auth/auth-context.decorator'; +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 { RampRoleMappingService } from '../services/ramp-role-mapping.service'; +import { + getManifest, + type RampUser, + type RampUsersResponse, + type RoleMappingEntry, +} from '@trycompai/integration-platform'; + +@Controller({ path: 'integrations/sync/ramp', version: '1' }) +@ApiTags('Integrations') +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class RampRoleMappingController { + constructor( + private readonly connectionRepository: ConnectionRepository, + private readonly credentialVaultService: CredentialVaultService, + private readonly oauthCredentialsService: OAuthCredentialsService, + private readonly roleMappingService: RampRoleMappingService, + ) {} + + @Post('discover-roles') + @RequirePermission('integration', 'update') + async discoverRoles( + @OrganizationId() organizationId: string, + @Query('connectionId') connectionId: string, + @Query('refresh') refresh?: string, + ) { + if (!connectionId) { + throw new HttpException('connectionId is required', HttpStatus.BAD_REQUEST); + } + + const shouldRefresh = refresh === 'true'; + let discoveredRoles: Array<{ role: string; userCount: number }>; + + // Use cached roles unless refresh is requested + const cachedRoles = shouldRefresh + ? null + : await this.roleMappingService.getCachedDiscoveredRoles(connectionId); + + if (cachedRoles) { + discoveredRoles = cachedRoles; + } else { + const accessToken = await this.getAccessToken(connectionId, organizationId); + const users = await this.fetchAllRampUsers(accessToken); + + const roleCounts = new Map(); + for (const user of users) { + const role = user.role ?? 'UNKNOWN'; + roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1); + } + + discoveredRoles = Array.from(roleCounts.entries()) + .map(([role, userCount]) => ({ role, userCount })) + .sort((a, b) => b.userCount - a.userCount); + + // Cache the discovered roles + const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); + await this.roleMappingService.saveMapping( + connectionId, + existingMapping ?? [], + discoveredRoles, + ); + } + + const rampRoleNames = discoveredRoles.map((r) => r.role); + const defaultMapping = this.roleMappingService.getDefaultMapping(rampRoleNames); + const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); + + // Fetch existing custom roles for this org with their permissions + const customRoles = await db.organizationRole.findMany({ + where: { organizationId }, + select: { name: true, permissions: true, obligations: true }, + orderBy: { name: 'asc' }, + }); + + const existingCustomRoles = customRoles.map((r) => ({ + name: r.name, + permissions: JSON.parse(r.permissions) as Record, + obligations: JSON.parse(r.obligations) as Record, + })); + + return { discoveredRoles, defaultMapping, existingMapping, existingCustomRoles }; + } + + @Post('role-mapping') + @RequirePermission('integration', 'update') + async saveRoleMapping( + @OrganizationId() organizationId: string, + @Body() body: { connectionId: string; mapping: RoleMappingEntry[] }, + ) { + const { connectionId, mapping } = body; + + if (!connectionId || !Array.isArray(mapping)) { + throw new HttpException( + 'connectionId and mapping are required', + HttpStatus.BAD_REQUEST, + ); + } + + const connection = await this.connectionRepository.findById(connectionId); + if (!connection || connection.organizationId !== organizationId) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + + // Create custom roles in the database + await this.roleMappingService.ensureCustomRolesExist(organizationId, mapping); + + // Save mapping to connection variables (preserve existing discovered roles) + await this.roleMappingService.saveMapping(connectionId, mapping); + + return { success: true, mapping }; + } + + @Get('role-mapping') + @RequirePermission('integration', 'read') + async getRoleMapping( + @Query('connectionId') connectionId: string, + ) { + if (!connectionId) { + throw new HttpException('connectionId is required', HttpStatus.BAD_REQUEST); + } + + const mapping = await this.roleMappingService.getSavedMapping(connectionId); + return { mapping }; + } + + private async getAccessToken( + connectionId: string, + organizationId: string, + ): Promise { + let credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + + if (!credentials?.access_token) { + throw new HttpException( + 'No valid credentials. Please reconnect.', + HttpStatus.UNAUTHORIZED, + ); + } + + const manifest = getManifest('ramp'); + const oauthConfig = + manifest?.auth.type === 'oauth2' ? manifest.auth.config : null; + + if (oauthConfig?.supportsRefreshToken && credentials.refresh_token) { + try { + const oauthCreds = await this.oauthCredentialsService.getCredentials( + 'ramp', + organizationId, + ); + if (oauthCreds) { + const newToken = await this.credentialVaultService.refreshOAuthTokens( + connectionId, + { + tokenUrl: oauthConfig.tokenUrl, + refreshUrl: oauthConfig.refreshUrl, + clientId: oauthCreds.clientId, + clientSecret: oauthCreds.clientSecret, + clientAuthMethod: oauthConfig.clientAuthMethod, + }, + ); + if (newToken) { + credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + } + } + } catch { + // Try with existing token + } + } + + if (!credentials?.access_token) { + throw new HttpException( + 'No valid credentials. Please reconnect.', + HttpStatus.UNAUTHORIZED, + ); + } + + const token = credentials.access_token; + return Array.isArray(token) ? token[0] : token; + } + + private async fetchAllRampUsers(accessToken: string): Promise { + const users: RampUser[] = []; + let nextUrl: string | null = null; + + do { + const url = nextUrl + ? new URL(nextUrl) + : new URL('https://demo-api.ramp.com/developer/v1/users'); + if (!nextUrl) { + url.searchParams.set('page_size', '100'); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new HttpException( + 'Failed to fetch users from Ramp', + HttpStatus.BAD_GATEWAY, + ); + } + + const data: RampUsersResponse = await response.json(); + if (data.data?.length) { + users.push(...data.data); + } + nextUrl = data.page?.next ?? null; + } while (nextUrl); + + return users; + } +} diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index d050b99ba2..e49d65de7d 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -13,7 +13,11 @@ import { ApiTags, ApiSecurity } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; -import { OrganizationId } from '../../auth/auth-context.decorator'; +import { + OrganizationId, + AuthContext, +} from '../../auth/auth-context.decorator'; +import type { AuthContext as AuthContextType } from '../../auth/types'; import { db } from '@db'; import { ConnectionRepository } from '../repositories/connection.repository'; import { CredentialVaultService } from '../services/credential-vault.service'; @@ -24,7 +28,9 @@ import { type RampUser, type RampUserStatus, type RampUsersResponse, + type RoleMappingEntry, } from '@trycompai/integration-platform'; +import { RampRoleMappingService } from '../services/ramp-role-mapping.service'; interface GoogleWorkspaceUser { id: string; @@ -103,6 +109,7 @@ export class SyncController { private readonly connectionRepository: ConnectionRepository, private readonly credentialVaultService: CredentialVaultService, private readonly oauthCredentialsService: OAuthCredentialsService, + private readonly rampRoleMappingService: RampRoleMappingService, ) {} /** @@ -977,6 +984,7 @@ export class SyncController { async syncRampEmployees( @OrganizationId() organizationId: string, @Query('connectionId') connectionId: string, + @AuthContext() authContext: AuthContextType, ) { if (!connectionId) { throw new HttpException( @@ -1081,7 +1089,7 @@ export class SyncController { do { const url = nextUrl ? new URL(nextUrl) - : new URL('https://api.ramp.com/developer/v1/users'); + : new URL('https://demo-api.ramp.com/developer/v1/users'); if (!nextUrl) { url.searchParams.set('page_size', '100'); if (status) { @@ -1208,6 +1216,112 @@ export class SyncController { `Found ${activeUsers.length} active, ${inactiveUsers.length} inactive, and ${suspendedUsers.length} suspended Ramp users`, ); + // Load role mapping from connection variables + const connectionVars = (connection.variables ?? {}) as Record< + string, + unknown + >; + let roleMapping = Array.isArray(connectionVars.role_mapping) + ? (connectionVars.role_mapping as RoleMappingEntry[]) + : null; + + if (!roleMapping) { + const isAutomatedSync = authContext.authType === 'service'; + + if (!isAutomatedSync) { + // Manual sync — prompt user to configure mapping via UI + return { + success: false, + requiresRoleMapping: true, + message: + 'Role mapping is not configured. Please configure role mapping before syncing.', + }; + } + + // Automated sync (cron) — auto-generate default mapping + const allRampRolesForDefault = [ + ...new Set( + activeUsers + .map((u) => u.role) + .filter((r): r is string => Boolean(r)), + ), + ]; + + if (allRampRolesForDefault.length === 0) { + this.logger.warn( + 'No Ramp roles found to auto-generate mapping', + ); + return { + success: true, + totalFound: 0, + imported: 0, + skipped: 0, + deactivated: 0, + reactivated: 0, + errors: 0, + details: [], + }; + } + + const defaultEntries = + this.rampRoleMappingService.getDefaultMapping( + allRampRolesForDefault, + ); + + await this.rampRoleMappingService.ensureCustomRolesExist( + organizationId, + defaultEntries, + ); + await this.rampRoleMappingService.saveMapping( + connectionId, + defaultEntries, + ); + + roleMapping = defaultEntries; + + this.logger.log( + `Auto-generated default role mapping for Ramp sync (${defaultEntries.length} roles)`, + ); + } + + // Discover all Ramp roles in this batch and auto-create mappings for unknown ones + const allRampRoles = new Set( + activeUsers + .map((u) => u.role) + .filter((r): r is string => Boolean(r)), + ); + const mappedRoles = new Set(roleMapping.map((m) => m.rampRole)); + const newRoles = [...allRampRoles].filter((r) => !mappedRoles.has(r)); + + if (newRoles.length > 0) { + this.logger.log( + `Found ${newRoles.length} new Ramp roles not in mapping: ${newRoles.join(', ')}`, + ); + + const newEntries = + this.rampRoleMappingService.getDefaultMapping(newRoles); + + // Create custom roles in DB for new entries + await this.rampRoleMappingService.ensureCustomRolesExist( + organizationId, + newEntries, + ); + + // Add to mapping and save + const updatedMapping = [...roleMapping, ...newEntries]; + await this.rampRoleMappingService.saveMapping( + connectionId, + updatedMapping, + ); + + // Use the updated mapping + roleMapping.push(...newEntries); + } + + const roleMappingLookup = new Map( + roleMapping.map((m) => [m.rampRole, m.compRole]), + ); + const results = { imported: 0, skipped: 0, @@ -1258,25 +1372,41 @@ export class SyncController { } if (existingMember) { - // Backfill external ID if not set + const mappedRole = + roleMappingLookup.get(rampUser.role ?? '') ?? 'employee'; + + // Build update data: backfill external ID + update role if changed + const updateData: Record = {}; + if ( rampUser.id && (!existingMember.externalUserId || existingMember.externalUserSource !== 'ramp') ) { - await db.member.update({ - where: { id: existingMember.id }, - data: { - externalUserId: rampUser.id, - externalUserSource: 'ramp', - }, - }); + updateData.externalUserId = rampUser.id; + updateData.externalUserSource = 'ramp'; + } + + // Update role if it changed (but don't downgrade privileged roles) + const currentRoles = existingMember.role + .split(',') + .map((r) => r.trim().toLowerCase()); + const isPrivileged = + currentRoles.includes('owner') || + currentRoles.includes('admin') || + currentRoles.includes('auditor'); + + if (!isPrivileged && existingMember.role !== mappedRole) { + updateData.role = mappedRole; } if (existingMember.deactivated) { + updateData.deactivated = false; + updateData.isActive = true; + await db.member.update({ where: { id: existingMember.id }, - data: { deactivated: false, isActive: true }, + data: updateData, }); results.reactivated++; results.details.push({ @@ -1284,6 +1414,20 @@ export class SyncController { status: 'reactivated', reason: 'User is active again in Ramp', }); + } else if (Object.keys(updateData).length > 0) { + await db.member.update({ + where: { id: existingMember.id }, + data: updateData, + }); + results.skipped++; + results.details.push({ + email: normalizedEmail, + status: 'skipped', + reason: + updateData.role + ? `Role updated to ${mappedRole}` + : 'Already a member', + }); } else { results.skipped++; results.details.push({ @@ -1314,11 +1458,14 @@ export class SyncController { }); } + const mappedRole = + roleMappingLookup.get(rampUser.role ?? '') ?? 'employee'; + await db.member.create({ data: { organizationId, userId: existingUser.id, - role: 'employee', + role: mappedRole, isActive: true, externalUserId: rampUser.id || null, externalUserSource: rampUser.id ? 'ramp' : null, @@ -1419,6 +1566,12 @@ export class SyncController { `Ramp sync complete: ${results.imported} imported, ${results.reactivated} reactivated, ${results.deactivated} deactivated, ${results.skipped} skipped, ${results.errors} errors`, ); + // Update lastSyncAt on the connection + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { lastSyncAt: new Date() }, + }); + return { success: true, totalFound: activeUsers.length, diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index ab538f4e44..e027b41daf 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -10,6 +10,7 @@ import { VariablesController } from './controllers/variables.controller'; import { TaskIntegrationsController } from './controllers/task-integrations.controller'; import { WebhookController } from './controllers/webhook.controller'; import { SyncController } from './controllers/sync.controller'; +import { RampRoleMappingController } from './controllers/ramp-role-mapping.controller'; import { CredentialVaultService } from './services/credential-vault.service'; import { ConnectionService } from './services/connection.service'; import { OAuthCredentialsService } from './services/oauth-credentials.service'; @@ -26,6 +27,7 @@ import { PlatformCredentialRepository } from './repositories/platform-credential import { CheckRunRepository } from './repositories/check-run.repository'; import { DynamicIntegrationRepository } from './repositories/dynamic-integration.repository'; import { DynamicCheckRepository } from './repositories/dynamic-check.repository'; +import { RampRoleMappingService } from './services/ramp-role-mapping.service'; @Module({ imports: [AuthModule], @@ -40,6 +42,7 @@ import { DynamicCheckRepository } from './repositories/dynamic-check.repository' TaskIntegrationsController, WebhookController, SyncController, + RampRoleMappingController, ], providers: [ // Services @@ -50,6 +53,7 @@ import { DynamicCheckRepository } from './repositories/dynamic-check.repository' OAuthTokenRevocationService, ConnectionAuthTeardownService, DynamicManifestLoaderService, + RampRoleMappingService, // Repositories ProviderRepository, ConnectionRepository, diff --git a/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts new file mode 100644 index 0000000000..1ad8262b64 --- /dev/null +++ b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts @@ -0,0 +1,217 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { db, type Prisma } from '@db'; +import type { RoleMappingEntry } from '@trycompai/integration-platform'; + +const BUILT_IN_ROLES = ['owner', 'admin', 'auditor', 'employee', 'contractor']; + +/** Ramp roles that map to portal-only (employee-like) access */ +const EMPLOYEE_LIKE_ROLES = new Set(['BUSINESS_USER', 'GUEST_USER']); + +/** Default Ramp → CompAI role mappings */ +const DEFAULT_BUILT_IN_MAPPINGS: Record = { + BUSINESS_OWNER: 'admin', + BUSINESS_ADMIN: 'admin', + AUDITOR: 'auditor', + BUSINESS_USER: 'employee', +}; + +/** Read-only permissions for custom roles with app access */ +const APP_READ_ONLY_PERMISSIONS: Record = { + app: ['read'], + organization: ['read'], + member: ['read'], + control: ['read'], + evidence: ['read'], + policy: ['read'], + risk: ['read'], + vendor: ['read'], + task: ['read'], + framework: ['read'], + audit: ['read'], + finding: ['read'], + questionnaire: ['read'], + integration: ['read'], + trust: ['read'], + portal: ['read', 'update'], +}; + +/** Portal-only permissions (employee-like) */ +const PORTAL_ONLY_PERMISSIONS: Record = { + policy: ['read'], + portal: ['read', 'update'], +}; + +@Injectable() +export class RampRoleMappingService { + private readonly logger = new Logger(RampRoleMappingService.name); + + /** + * Generate default mapping entries for discovered Ramp roles + */ + getDefaultMapping(rampRoles: string[]): RoleMappingEntry[] { + return rampRoles.map((rampRole) => { + const builtInMatch = DEFAULT_BUILT_IN_MAPPINGS[rampRole]; + + if (builtInMatch) { + return { + rampRole, + compRole: builtInMatch, + isBuiltIn: true, + }; + } + + // Custom role — use raw Ramp role name, determine permissions based on whether it's employee-like + const isEmployeeLike = EMPLOYEE_LIKE_ROLES.has(rampRole); + + return { + rampRole, + compRole: rampRole, + isBuiltIn: false, + permissions: isEmployeeLike + ? PORTAL_ONLY_PERMISSIONS + : APP_READ_ONLY_PERMISSIONS, + obligations: isEmployeeLike + ? { compliance: true } + : ({} as Record), + }; + }); + } + + /** + * Ensure all custom roles in the mapping exist in the database. + * Creates missing ones. Returns a map of compRole name → role name (for assignment). + */ + async ensureCustomRolesExist( + organizationId: string, + mapping: RoleMappingEntry[], + ): Promise { + const customEntries = mapping.filter((m) => !m.isBuiltIn); + + for (const entry of customEntries) { + const existing = await db.organizationRole.findFirst({ + where: { organizationId, name: entry.compRole }, + }); + + if (existing) { + this.logger.log( + `Custom role "${entry.compRole}" already exists for org ${organizationId}`, + ); + continue; + } + + // Check max roles limit + const roleCount = await db.organizationRole.count({ + where: { organizationId }, + }); + + if (roleCount >= 20) { + this.logger.warn( + `Cannot create role "${entry.compRole}" — max 20 custom roles reached`, + ); + continue; + } + + await db.organizationRole.create({ + data: { + name: entry.compRole, + permissions: JSON.stringify(entry.permissions ?? APP_READ_ONLY_PERMISSIONS), + obligations: JSON.stringify(entry.obligations ?? {}), + organizationId, + }, + }); + + this.logger.log( + `Created custom role "${entry.compRole}" for org ${organizationId}`, + ); + } + } + + /** + * Resolve a Ramp user's role to the CompAI role name using the mapping + */ + resolveRole( + rampRole: string | undefined, + mapping: RoleMappingEntry[], + ): string { + if (!rampRole) return 'employee'; + + const entry = mapping.find((m) => m.rampRole === rampRole); + if (!entry) return 'employee'; + + return entry.compRole; + } + + /** + * Get the saved role mapping from connection variables + */ + async getSavedMapping( + connectionId: string, + ): Promise { + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { variables: true }, + }); + + const variables = (connection?.variables ?? {}) as Record; + const mapping = variables.role_mapping; + + if (!Array.isArray(mapping) || mapping.length === 0) return null; + + return mapping as RoleMappingEntry[]; + } + + /** + * Save role mapping and discovered roles to connection variables + */ + async saveMapping( + connectionId: string, + mapping: RoleMappingEntry[], + discoveredRoles?: Array<{ role: string; userCount: number }>, + ): Promise { + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { variables: true }, + }); + + const existingVariables = (connection?.variables ?? {}) as Record< + string, + unknown + >; + + const updatedVariables: Record = { + ...existingVariables, + role_mapping: mapping, + }; + + if (discoveredRoles) { + updatedVariables.discovered_roles = discoveredRoles; + } + + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { + variables: updatedVariables as unknown as Prisma.InputJsonValue, + }, + }); + } + + /** + * Get cached discovered roles from connection variables + */ + async getCachedDiscoveredRoles( + connectionId: string, + ): Promise | null> { + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { variables: true }, + }); + + const variables = (connection?.variables ?? {}) as Record; + const roles = variables.discovered_roles; + + if (!Array.isArray(roles) || roles.length === 0) return null; + + return roles as Array<{ role: string; userCount: number }>; + } + +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/RampRoleMappingSheet.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/RampRoleMappingSheet.tsx new file mode 100644 index 0000000000..f99be043ed --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/RampRoleMappingSheet.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Button } from '@trycompai/design-system'; +import { Close } from '@trycompai/design-system/icons'; +import { apiClient } from '@/lib/api-client'; +import { toast } from 'sonner'; +import { RampRoleMappingRow } from '../../role-mapping/components/RampRoleMappingRow'; +import type { RoleMappingEntry } from '../../role-mapping/components/RampRoleMappingRow'; +import type { RoleMappingData } from '../hooks/useEmployeeSync'; + +interface RampRoleMappingSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + data: RoleMappingData; + onSaved: () => void; +} + +export function RampRoleMappingSheet({ + open, + onOpenChange, + organizationId, + data, + onSaved, +}: RampRoleMappingSheetProps) { + const initialMapping = data.existingMapping ?? data.defaultMapping; + const [mapping, setMapping] = useState(initialMapping); + const [isSaving, setIsSaving] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const handleEntryChange = (index: number, updated: RoleMappingEntry) => { + setMapping((prev) => { + const next = [...prev]; + next[index] = updated; + return next; + }); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const response = await apiClient.post( + `/v1/integrations/sync/ramp/role-mapping?organizationId=${organizationId}`, + { connectionId: data.connectionId, mapping }, + ); + + if (response.error) { + toast.error(response.error); + return; + } + + toast.success('Role mapping saved'); + onSaved(); + } catch { + toast.error('Failed to save role mapping'); + } finally { + setIsSaving(false); + } + }; + + if (!open || !mounted) return null; + + return createPortal( + <> + {/* Backdrop */} +
onOpenChange(false)} + /> + + {/* Dialog */} +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Configure Ramp Role Mapping +

+

+ Map Ramp roles to your organization's roles. Custom roles + will be created automatically with default permissions you can + customize. +

+
+ +
+ + {/* Column headers */} +
+

+ Ramp Role +

+
+

+ Comp AI Role +

+
+ + {/* Mapping rows */} +
+
+ {mapping.map((entry, index) => ( + handleEntryChange(index, updated)} + /> + ))} +
+
+ + {/* Footer */} +
+ + +
+
+
+ , + document.body, + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index c3c9a72dc3..1ec310de51 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -32,12 +32,14 @@ import { TableHead, TableHeader, TableRow, + Button, } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; +import { RampRoleMappingSheet } from './RampRoleMappingSheet'; import type { MemberWithUser, TeamMembersData } from './TeamMembers'; import type { EmployeeSyncConnectionsData } from '../data/queries'; @@ -114,6 +116,11 @@ export function TeamMembersClient({ hasAnyConnection, getProviderName, getProviderLogo, + showRoleMappingSheet, + roleMappingData, + handleRoleMappingClose, + handleRoleMappingSaved, + openRoleMappingEditor, } = useEmployeeSync({ organizationId, initialData: employeeSyncData }); const lastSyncAt = employeeSyncData.lastSyncAt; @@ -336,39 +343,40 @@ export function TeamMembersClient({
{hasAnyConnection && ( -
- { + if (value) { + handleEmployeeSync( + value as 'google-workspace' | 'rippling' | 'jumpcloud' | 'ramp', + ); + } + }} + disabled={isSyncing || !canManageMembers} + > + + {isSyncing ? ( + <> + + Syncing... + + ) : selectedProvider ? ( +
+ {getProviderName(selectedProvider)} + {getProviderName(selectedProvider)} +
+ ) : ( + + )} +
{selectedProvider ? ( @@ -463,7 +471,8 @@ export function TeamMembersClient({ )} - + +
)}
@@ -536,6 +545,17 @@ export function TeamMembersClient({ )} + {roleMappingData && ( + { + if (!open) handleRoleMappingClose(); + }} + organizationId={organizationId} + data={roleMappingData} + onSaved={handleRoleMappingSaved} + /> + )} ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts index 9d56994869..2d0a7e6b8b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts @@ -11,6 +11,7 @@ type SyncProvider = 'google-workspace' | 'rippling' | 'jumpcloud' | 'ramp'; interface SyncResult { success: boolean; + requiresRoleMapping?: boolean; totalFound: number; imported: number; reactivated: number; @@ -19,6 +20,31 @@ interface SyncResult { errors: number; } +interface DiscoveredRole { + role: string; + userCount: number; +} + +interface RoleMappingEntry { + rampRole: string; + compRole: string; + isBuiltIn: boolean; + permissions?: Record; + obligations?: Record; +} + +export interface RoleMappingData { + discoveredRoles: DiscoveredRole[]; + defaultMapping: RoleMappingEntry[]; + existingMapping: RoleMappingEntry[] | null; + existingCustomRoles: Array<{ + name: string; + permissions: Record; + obligations: Record; + }>; + connectionId: string; +} + interface UseEmployeeSyncOptions { organizationId: string; initialData: EmployeeSyncConnectionsData; @@ -36,6 +62,11 @@ interface UseEmployeeSyncReturn { hasAnyConnection: boolean; getProviderName: (provider: SyncProvider) => string; getProviderLogo: (provider: SyncProvider) => string; + showRoleMappingSheet: boolean; + roleMappingData: RoleMappingData | null; + handleRoleMappingClose: () => void; + handleRoleMappingSaved: () => void; + openRoleMappingEditor: () => Promise; } const PROVIDER_CONFIG = { @@ -66,6 +97,9 @@ export const useEmployeeSync = ({ initialData, }: UseEmployeeSyncOptions): UseEmployeeSyncReturn => { const [isSyncing, setIsSyncing] = useState(false); + const [showRoleMappingSheet, setShowRoleMappingSheet] = useState(false); + const [roleMappingData, setRoleMappingData] = useState(null); + const [pendingSyncProvider, setPendingSyncProvider] = useState(null); const { data, mutate } = useSWR( ['employee-sync-connections', organizationId], @@ -126,6 +160,36 @@ export const useEmployeeSync = ({ `/v1/integrations/sync/${provider}/employees?organizationId=${organizationId}&connectionId=${connectionId}`, ); + // Handle role mapping requirement (Ramp only) + if (response.data?.requiresRoleMapping && provider === 'ramp' && connectionId) { + setPendingSyncProvider(provider); + try { + const discoverResponse = await apiClient.post<{ + discoveredRoles: DiscoveredRole[]; + defaultMapping: RoleMappingEntry[]; + existingMapping: RoleMappingEntry[] | null; + existingCustomRoles: Array<{ + name: string; + permissions: Record; + obligations: Record; + }>; + }>( + `/v1/integrations/sync/ramp/discover-roles?organizationId=${organizationId}&connectionId=${connectionId}`, + ); + + if (discoverResponse.data) { + setRoleMappingData({ + ...discoverResponse.data, + connectionId, + }); + setShowRoleMappingSheet(true); + } + } catch { + toast.error('Failed to discover Ramp roles'); + } + return null; + } + if (response.data?.success) { const { imported, reactivated, deactivated, skipped, errors } = response.data; @@ -166,6 +230,57 @@ export const useEmployeeSync = ({ const getProviderName = (provider: SyncProvider) => PROVIDER_CONFIG[provider].shortName; const getProviderLogo = (provider: SyncProvider) => PROVIDER_CONFIG[provider].logo; + const openRoleMappingEditor = async () => { + if (!rampConnectionId) { + toast.error('Ramp is not connected'); + return; + } + + try { + const discoverResponse = await apiClient.post<{ + discoveredRoles: DiscoveredRole[]; + defaultMapping: RoleMappingEntry[]; + existingMapping: RoleMappingEntry[] | null; + existingCustomRoles: Array<{ + name: string; + permissions: Record; + obligations: Record; + }>; + }>( + `/v1/integrations/sync/ramp/discover-roles?organizationId=${organizationId}&connectionId=${rampConnectionId}`, + ); + + if (discoverResponse.data) { + setRoleMappingData({ + ...discoverResponse.data, + connectionId: rampConnectionId, + }); + setShowRoleMappingSheet(true); + } + } catch { + toast.error('Failed to load role mapping'); + } + }; + + const handleRoleMappingClose = () => { + setShowRoleMappingSheet(false); + setRoleMappingData(null); + setPendingSyncProvider(null); + setIsSyncing(false); + }; + + const handleRoleMappingSaved = () => { + setShowRoleMappingSheet(false); + setRoleMappingData(null); + + // Retry sync with the pending provider now that mapping is saved + if (pendingSyncProvider) { + const provider = pendingSyncProvider; + setPendingSyncProvider(null); + syncEmployees(provider); + } + }; + return { googleWorkspaceConnectionId, ripplingConnectionId, @@ -183,5 +298,10 @@ export const useEmployeeSync = ({ ), getProviderName, getProviderLogo, + showRoleMappingSheet, + roleMappingData, + handleRoleMappingClose, + handleRoleMappingSaved, + openRoleMappingEditor, }; }; diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 6ca5600a09..1b1249871c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -19,6 +19,8 @@ interface PeoplePageTabsProps { employeeTasksContent: ReactNode | null; devicesContent: ReactNode; orgChartContent: ReactNode; + roleMappingContent: ReactNode | null; + showRoleMapping: boolean; showEmployeeTasks: boolean; canInviteUsers: boolean; canManageMembers: boolean; @@ -30,6 +32,8 @@ export function PeoplePageTabs({ employeeTasksContent, devicesContent, orgChartContent, + roleMappingContent, + showRoleMapping, showEmployeeTasks, canInviteUsers, canManageMembers, @@ -49,6 +53,7 @@ export function PeoplePageTabs({ {showEmployeeTasks && Tasks} Devices Chart + {showRoleMapping && Role Mapping} } actions={ @@ -69,6 +74,9 @@ export function PeoplePageTabs({ )} {devicesContent} {orgChartContent} + {showRoleMapping && ( + {roleMappingContent} + )} } + showRoleMapping={!!syncConnections?.rampConnectionId} + roleMappingContent={ + syncConnections?.rampConnectionId ? ( + + ) : null + } showEmployeeTasks={showEmployeeTasks} canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} diff --git a/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx new file mode 100644 index 0000000000..cdea022d99 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@trycompai/design-system'; +import { apiClient } from '@/lib/api-client'; +import { toast } from 'sonner'; +import { RampRoleMappingRow } from './RampRoleMappingRow'; +import type { RoleMappingEntry } from './RampRoleMappingRow'; + +interface DiscoveredRole { + role: string; + userCount: number; +} + +interface DiscoverRolesResponse { + discoveredRoles: DiscoveredRole[]; + defaultMapping: RoleMappingEntry[]; + existingMapping: RoleMappingEntry[] | null; + existingCustomRoles: Array<{ + name: string; + permissions: Record; + obligations: Record; + }>; +} + +interface RampRoleMappingContentProps { + organizationId: string; + connectionId: string; +} + +export function RampRoleMappingContent({ + organizationId, + connectionId, +}: RampRoleMappingContentProps) { + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [mapping, setMapping] = useState([]); + const savedMappingRef = useRef(''); + const [discoveredRoles, setDiscoveredRoles] = useState([]); + const [existingCustomRoles, setExistingCustomRoles] = useState< + Array<{ + name: string; + permissions: Record; + obligations: Record; + }> + >([]); + + const fetchRoles = async (refresh = false) => { + try { + const refreshParam = refresh ? '&refresh=true' : ''; + const response = await apiClient.post( + `/v1/integrations/sync/ramp/discover-roles?organizationId=${organizationId}&connectionId=${connectionId}${refreshParam}`, + ); + + if (response.data) { + const initialMapping = + response.data.existingMapping ?? response.data.defaultMapping; + setDiscoveredRoles(response.data.discoveredRoles); + setExistingCustomRoles(response.data.existingCustomRoles); + setMapping(initialMapping); + savedMappingRef.current = JSON.stringify(initialMapping); + } + } catch { + toast.error('Failed to load Ramp roles'); + } + }; + + useEffect(() => { + const load = async () => { + setIsLoading(true); + await fetchRoles(); + setIsLoading(false); + }; + load(); + }, [organizationId, connectionId]); + + const handleRefresh = async () => { + setIsRefreshing(true); + await fetchRoles(true); + setIsRefreshing(false); + toast.success('Roles refreshed from Ramp'); + }; + + const handleEntryChange = (index: number, updated: RoleMappingEntry) => { + setMapping((prev) => { + const next = [...prev]; + next[index] = updated; + return next; + }); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const response = await apiClient.post( + `/v1/integrations/sync/ramp/role-mapping?organizationId=${organizationId}`, + { connectionId, mapping }, + ); + + if (response.error) { + toast.error(response.error); + return; + } + + toast.success('Role mapping saved'); + savedMappingRef.current = JSON.stringify(mapping); + } catch { + toast.error('Failed to save role mapping'); + } finally { + setIsSaving(false); + } + }; + + const isDirty = JSON.stringify(mapping) !== savedMappingRef.current; + + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + if (mapping.length === 0) { + return ( +
+ No roles discovered from Ramp. Try syncing employees first. +
+ ); + } + + return ( +
+ {/* Column headers */} +
+

+ Ramp Role +

+
+

+ Comp AI Role +

+
+ + {/* Mapping rows */} +
+ {mapping.map((entry, index) => ( + handleEntryChange(index, updated)} + /> + ))} +
+ + {/* Info note */} +

+ Members with Owner, Admin, or Auditor roles in Comp AI are not + affected by sync — their roles are preserved. +

+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingRow.tsx b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingRow.tsx new file mode 100644 index 0000000000..61dc465f28 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingRow.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { ChevronDown, Settings } from '@trycompai/design-system/icons'; +import { PermissionMatrix } from '../../../settings/roles/components/PermissionMatrix'; + +const BUILT_IN_ROLES = [ + { value: 'admin', label: 'Admin' }, + { value: 'auditor', label: 'Auditor' }, + { value: 'employee', label: 'Employee' }, + { value: 'contractor', label: 'Contractor' }, +]; + +export interface RoleMappingEntry { + rampRole: string; + compRole: string; + isBuiltIn: boolean; + permissions?: Record; + obligations?: Record; +} + +interface RampRoleMappingRowProps { + entry: RoleMappingEntry; + existingCustomRoles: Array<{ + name: string; + permissions: Record; + obligations: Record; + }>; + onChange: (updated: RoleMappingEntry) => void; +} + +export function RampRoleMappingRow({ + entry, + existingCustomRoles, + onChange, +}: RampRoleMappingRowProps) { + const [expanded, setExpanded] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [customInput, setCustomInput] = useState(''); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + const displayValue = entry.isBuiltIn + ? BUILT_IN_ROLES.find((r) => r.value === entry.compRole)?.label ?? + entry.compRole + : entry.compRole; + + const handleSelectBuiltIn = (value: string) => { + onChange({ + ...entry, + compRole: value, + isBuiltIn: true, + permissions: undefined, + obligations: undefined, + }); + setDropdownOpen(false); + setCustomInput(''); + }; + + const handleCustomSubmit = () => { + const val = customInput.trim(); + if (!val) return; + + // Check if this matches an existing custom role — use its permissions + const existingRole = existingCustomRoles.find((r) => r.name === val); + + onChange({ + ...entry, + compRole: val, + isBuiltIn: false, + permissions: existingRole?.permissions ?? { + policy: ['read'], + portal: ['read', 'update'], + }, + obligations: existingRole?.obligations ?? {}, + }); + setDropdownOpen(false); + setCustomInput(''); + }; + + return ( +
+ {/* Two-column mapping row */} +
+ {/* Left: Ramp role */} +
+

{entry.rampRole}

+ {!entry.isBuiltIn && ( + + )} +
+ + {/* Arrow */} + + + {/* Right: Custom select with inline input */} +
+ + + {dropdownOpen && ( +
+ {BUILT_IN_ROLES.map((role) => ( + + ))} + + {/* Existing custom roles */} + {existingCustomRoles.length > 0 && ( + <> +
+

Custom roles

+
+ {existingCustomRoles.map((role) => ( + + ))} + + )} + + {/* New custom role input */} +
+
+ setCustomInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCustomSubmit(); + } + if (e.key === 'Escape') { + setDropdownOpen(false); + setCustomInput(''); + } + }} + placeholder="Custom role name..." + className="flex-1 text-sm px-2 py-1.5 rounded border outline-none focus:ring-1 focus:ring-foreground/20 bg-background" + /> + +
+
+
+ )} +
+
+ + {/* Expanded permissions for custom roles */} + {!entry.isBuiltIn && expanded && ( +
+ + onChange({ ...entry, permissions }) + } + obligations={entry.obligations} + onObligationsChange={(obligations) => + onChange({ ...entry, obligations }) + } + /> +
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RoleMappingTab.tsx b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RoleMappingTab.tsx new file mode 100644 index 0000000000..f4da227048 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RoleMappingTab.tsx @@ -0,0 +1,69 @@ +'use client'; + +import Image from 'next/image'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@trycompai/design-system'; +import { RampRoleMappingContent } from './RampRoleMappingContent'; + +const PROVIDER_LOGOS = { + ramp: 'https://img.logo.dev/ramp.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ&format=png&retina=true', +} as const; + +interface RoleMappingTabProps { + organizationId: string; + rampConnectionId: string | null; +} + +export function RoleMappingTab({ + organizationId, + rampConnectionId, +}: RoleMappingTabProps) { + if (!rampConnectionId) { + return ( + + + No sync providers connected + + Connect a provider like Ramp in Integrations to configure role + mapping. + + + + ); + } + + return ( + +
+ + + Ramp + Ramp + + +
+ + + + +
+ ); +} diff --git a/apps/app/tsconfig.json b/apps/app/tsconfig.json index 39c4756d54..d1e8eeec0a 100644 --- a/apps/app/tsconfig.json +++ b/apps/app/tsconfig.json @@ -107,12 +107,6 @@ ], "@trycompai/tsconfig/*": [ "../../packages/tsconfig/*" - ], - "@trycompai/email": [ - "../../packages/email/index.ts" - ], - "@trycompai/email/*": [ - "../../packages/email/*" ] } }, diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 1fadeff181..a8618e3d74 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -43,17 +43,7 @@ "@trycompai/analytics": ["../../packages/analytics/src/index.ts"], "@trycompai/analytics/*": ["../../packages/analytics/src/*"], "@trycompai/tsconfig": ["../../packages/tsconfig"], - "@trycompai/tsconfig/*": ["../../packages/tsconfig/*"], - "@trycompai/email": ["../../packages/email/index.ts"], - "@trycompai/email/*": ["../../packages/email/*"], - "@trycompai/kv": ["../../packages/kv/src/index.ts"], - "@trycompai/kv/*": ["../../packages/kv/src/*"], - "@trycompai/ui": ["../../packages/ui/src/components/index.ts"], - "@trycompai/ui/*": ["../../packages/ui/src/components/*"], - "@trycompai/utils": ["../../packages/utils/src/index.ts"], - "@trycompai/utils/*": ["../../packages/utils/src/*"], - "@trycompai/analytics": ["../../packages/analytics/src/index.ts"], - "@trycompai/analytics/*": ["../../packages/analytics/src/*"] + "@trycompai/tsconfig/*": ["../../packages/tsconfig/*"] } }, "include": [ diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 8a41114524..2d57b05d9d 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -15664,6 +15664,87 @@ ] } }, + "/v1/integrations/sync/ramp/discover-roles": { + "post": { + "operationId": "RampRoleMappingController_discoverRoles_v1", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "refresh", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "tags": [ + "Integrations" + ] + } + }, + "/v1/integrations/sync/ramp/role-mapping": { + "post": { + "operationId": "RampRoleMappingController_saveRoleMapping_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "tags": [ + "Integrations" + ] + }, + "get": { + "operationId": "RampRoleMappingController_getRoleMapping_v1", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "tags": [ + "Integrations" + ] + } + }, "/v1/cloud-security/providers": { "get": { "operationId": "CloudSecurityController_getProviders_v1", diff --git a/packages/integration-platform/src/index.ts b/packages/integration-platform/src/index.ts index 105997e4cf..6bcf8d1561 100644 --- a/packages/integration-platform/src/index.ts +++ b/packages/integration-platform/src/index.ts @@ -125,8 +125,10 @@ export type { RampUser, RampUserStatus, RampUserRole, + RampKnownRole, RampEmployee, RampUsersResponse, + RoleMappingEntry, } from './manifests/ramp/types'; // API Response types (for frontend and API type sharing) diff --git a/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts b/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts index 6e037b556e..cc57c9db35 100644 --- a/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts +++ b/packages/integration-platform/src/manifests/ramp/checks/employee-sync.ts @@ -66,7 +66,7 @@ export const employeeSyncCheck: IntegrationCheck = { while (currentUrl) { const response: RampUsersResponse = await ctx.fetch( currentUrl, - isFirst ? { baseUrl: 'https://api.ramp.com' } : undefined, + isFirst ? { baseUrl: 'https://demo-api.ramp.com' } : undefined, ); isFirst = false; diff --git a/packages/integration-platform/src/manifests/ramp/index.ts b/packages/integration-platform/src/manifests/ramp/index.ts index 0d51731208..2e7b140efc 100644 --- a/packages/integration-platform/src/manifests/ramp/index.ts +++ b/packages/integration-platform/src/manifests/ramp/index.ts @@ -20,14 +20,14 @@ export const rampManifest: IntegrationManifest = { auth: { type: 'oauth2', config: { - authorizeUrl: 'https://app.ramp.com/v1/authorize', - tokenUrl: 'https://api.ramp.com/developer/v1/token', + authorizeUrl: 'https://demo.ramp.com/v1/authorize', + tokenUrl: 'https://demo-api.ramp.com/developer/v1/token', scopes: ['users:read'], pkce: false, clientAuthMethod: 'header', supportsRefreshToken: true, revoke: { - url: 'https://api.ramp.com/developer/v1/token/revoke', + url: 'https://demo-api.ramp.com/developer/v1/token/revoke', method: 'POST', auth: 'basic', body: 'form', @@ -42,7 +42,7 @@ export const rampManifest: IntegrationManifest = { }, }, - baseUrl: 'https://api.ramp.com', + baseUrl: 'https://demo-api.ramp.com', defaultHeaders: { Accept: 'application/json', }, diff --git a/packages/integration-platform/src/manifests/ramp/types.ts b/packages/integration-platform/src/manifests/ramp/types.ts index 347fbf8c29..72e032b708 100644 --- a/packages/integration-platform/src/manifests/ramp/types.ts +++ b/packages/integration-platform/src/manifests/ramp/types.ts @@ -6,7 +6,7 @@ export type RampUserStatus = | 'INVITE_EXPIRED' | 'USER_ONBOARDING'; -export type RampUserRole = +export type RampKnownRole = | 'AUDITOR' | 'BUSINESS_ADMIN' | 'BUSINESS_BOOKKEEPER' @@ -15,6 +15,9 @@ export type RampUserRole = | 'GUEST_USER' | 'IT_ADMIN'; +// Ramp can also have custom roles — allow any string +export type RampUserRole = RampKnownRole | (string & {}); + export interface RampUser { id: string; email: string; @@ -57,3 +60,11 @@ export interface RampUsersResponse { data: RampUser[]; page: RampPage; } + +export interface RoleMappingEntry { + rampRole: string; + compRole: string; + isBuiltIn: boolean; + permissions?: Record; + obligations?: Record; +} From 13e644659bee5dfe38de30b75481a9f3f284dd73 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 09:16:43 -0400 Subject: [PATCH 05/15] feat(integration-platform): integrate logging for role mapping and sync operations - Added IntegrationSyncLoggerService to log role discovery and mapping processes. - Updated RampRoleMappingController to log role discovery events and results. - Enhanced SyncController to log employee sync operations and their outcomes. - Modified Prisma schema to include sync logs in IntegrationConnection and Organization models. --- .../ramp-role-mapping.controller.ts | 87 +++++++++++++----- .../controllers/sync.controller.ts | 56 +++++++++++- .../integration-platform.module.ts | 2 + .../integration-sync-logger.service.ts | 91 +++++++++++++++++++ .../migration.sql | 40 ++++++++ .../prisma/schema/integration-platform.prisma | 1 + .../prisma/schema/integration-sync-log.prisma | 45 +++++++++ packages/db/prisma/schema/organization.prisma | 1 + 8 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/integration-platform/services/integration-sync-logger.service.ts create mode 100644 packages/db/prisma/migrations/20260316194210_add_integration_sync_log/migration.sql create mode 100644 packages/db/prisma/schema/integration-sync-log.prisma diff --git a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts index 20b6f70f39..f278dcd369 100644 --- a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -18,6 +18,7 @@ import { ConnectionRepository } from '../repositories/connection.repository'; import { CredentialVaultService } from '../services/credential-vault.service'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { RampRoleMappingService } from '../services/ramp-role-mapping.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; import { getManifest, type RampUser, @@ -35,6 +36,7 @@ export class RampRoleMappingController { private readonly credentialVaultService: CredentialVaultService, private readonly oauthCredentialsService: OAuthCredentialsService, private readonly roleMappingService: RampRoleMappingService, + private readonly syncLoggerService: IntegrationSyncLoggerService, ) {} @Post('discover-roles') @@ -59,26 +61,47 @@ export class RampRoleMappingController { if (cachedRoles) { discoveredRoles = cachedRoles; } else { - const accessToken = await this.getAccessToken(connectionId, organizationId); - const users = await this.fetchAllRampUsers(accessToken); + const logId = await this.syncLoggerService.startLog({ + connectionId, + organizationId, + provider: 'ramp', + eventType: 'role_discovery', + triggeredBy: 'manual', + }); - const roleCounts = new Map(); - for (const user of users) { - const role = user.role ?? 'UNKNOWN'; - roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1); - } + try { + const accessToken = await this.getAccessToken(connectionId, organizationId); + const users = await this.fetchAllRampUsers(accessToken); - discoveredRoles = Array.from(roleCounts.entries()) - .map(([role, userCount]) => ({ role, userCount })) - .sort((a, b) => b.userCount - a.userCount); + const roleCounts = new Map(); + for (const user of users) { + const role = user.role ?? 'UNKNOWN'; + roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1); + } - // Cache the discovered roles - const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); - await this.roleMappingService.saveMapping( - connectionId, - existingMapping ?? [], - discoveredRoles, - ); + discoveredRoles = Array.from(roleCounts.entries()) + .map(([role, userCount]) => ({ role, userCount })) + .sort((a, b) => b.userCount - a.userCount); + + // Cache the discovered roles + const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); + await this.roleMappingService.saveMapping( + connectionId, + existingMapping ?? [], + discoveredRoles, + ); + + await this.syncLoggerService.completeLog(logId, { + rolesDiscovered: discoveredRoles.length, + totalUsers: users.length, + }); + } catch (error) { + await this.syncLoggerService.failLog( + logId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } } const rampRoleNames = discoveredRoles.map((r) => r.role); @@ -121,13 +144,33 @@ export class RampRoleMappingController { throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); } - // Create custom roles in the database - await this.roleMappingService.ensureCustomRolesExist(organizationId, mapping); + const logId = await this.syncLoggerService.startLog({ + connectionId, + organizationId, + provider: 'ramp', + eventType: 'role_mapping_save', + triggeredBy: 'manual', + }); + + try { + // Create custom roles in the database + await this.roleMappingService.ensureCustomRolesExist(organizationId, mapping); - // Save mapping to connection variables (preserve existing discovered roles) - await this.roleMappingService.saveMapping(connectionId, mapping); + // Save mapping to connection variables (preserve existing discovered roles) + await this.roleMappingService.saveMapping(connectionId, mapping); - return { success: true, mapping }; + await this.syncLoggerService.completeLog(logId, { + mappingCount: mapping.length, + }); + + return { success: true, mapping }; + } catch (error) { + await this.syncLoggerService.failLog( + logId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } } @Get('role-mapping') diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index e49d65de7d..fbda0c3247 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -31,6 +31,7 @@ import { type RoleMappingEntry, } from '@trycompai/integration-platform'; import { RampRoleMappingService } from '../services/ramp-role-mapping.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; interface GoogleWorkspaceUser { id: string; @@ -110,6 +111,7 @@ export class SyncController { private readonly credentialVaultService: CredentialVaultService, private readonly oauthCredentialsService: OAuthCredentialsService, private readonly rampRoleMappingService: RampRoleMappingService, + private readonly syncLoggerService: IntegrationSyncLoggerService, ) {} /** @@ -1013,6 +1015,46 @@ export class SyncController { ); } + const triggeredBy = + authContext.authType === 'service' + ? 'scheduled' + : authContext.authType === 'api-key' + ? 'api' + : 'manual'; + + const logId = await this.syncLoggerService.startLog({ + connectionId, + organizationId, + provider: 'ramp', + eventType: 'employee_sync', + triggeredBy, + userId: authContext.userId ?? undefined, + }); + + try { + return await this.syncRampEmployeesInner( + organizationId, + connectionId, + authContext, + connection, + logId, + ); + } catch (error) { + await this.syncLoggerService.failLog( + logId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + private async syncRampEmployeesInner( + organizationId: string, + connectionId: string, + authContext: AuthContextType, + connection: { variables: unknown }, + logId: string, + ) { const manifest = getManifest('ramp'); if (!manifest) { throw new HttpException( @@ -1572,13 +1614,25 @@ export class SyncController { data: { lastSyncAt: new Date() }, }); - return { + const syncResult = { success: true, totalFound: activeUsers.length, totalInactive: inactiveUsers.length, totalSuspended: suspendedUsers.length, ...results, }; + + await this.syncLoggerService.completeLog(logId, { + imported: results.imported, + deactivated: results.deactivated, + reactivated: results.reactivated, + skipped: results.skipped, + errors: results.errors, + totalFound: activeUsers.length, + details: results.details, + }); + + return syncResult; } /** diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index e027b41daf..45806aa92a 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -28,6 +28,7 @@ import { CheckRunRepository } from './repositories/check-run.repository'; import { DynamicIntegrationRepository } from './repositories/dynamic-integration.repository'; import { DynamicCheckRepository } from './repositories/dynamic-check.repository'; import { RampRoleMappingService } from './services/ramp-role-mapping.service'; +import { IntegrationSyncLoggerService } from './services/integration-sync-logger.service'; @Module({ imports: [AuthModule], @@ -54,6 +55,7 @@ import { RampRoleMappingService } from './services/ramp-role-mapping.service'; ConnectionAuthTeardownService, DynamicManifestLoaderService, RampRoleMappingService, + IntegrationSyncLoggerService, // Repositories ProviderRepository, ConnectionRepository, diff --git a/apps/api/src/integration-platform/services/integration-sync-logger.service.ts b/apps/api/src/integration-platform/services/integration-sync-logger.service.ts new file mode 100644 index 0000000000..5b402376c4 --- /dev/null +++ b/apps/api/src/integration-platform/services/integration-sync-logger.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@prisma/client'; + +interface StartLogParams { + connectionId: string; + organizationId: string; + provider: string; + eventType: string; + triggeredBy?: string; + userId?: string; +} + +@Injectable() +export class IntegrationSyncLoggerService { + private readonly logger = new Logger(IntegrationSyncLoggerService.name); + + async startLog({ + connectionId, + organizationId, + provider, + eventType, + triggeredBy, + userId, + }: StartLogParams): Promise { + const log = await db.integrationSyncLog.create({ + data: { + connectionId, + organizationId, + provider, + eventType, + status: 'running', + startedAt: new Date(), + triggeredBy: triggeredBy ?? null, + userId: userId ?? null, + }, + }); + + this.logger.log( + `Sync log started: ${log.id} (${provider}/${eventType})`, + ); + + return log.id; + } + + async completeLog(id: string, result: Record): Promise { + const log = await db.integrationSyncLog.findUnique({ where: { id } }); + if (!log) { + this.logger.warn(`Sync log not found: ${id}`); + return; + } + + const now = new Date(); + const durationMs = log.startedAt + ? now.getTime() - log.startedAt.getTime() + : null; + + await db.integrationSyncLog.update({ + where: { id }, + data: { + status: 'success', + completedAt: now, + durationMs, + result: result as Prisma.InputJsonValue, + }, + }); + } + + async failLog(id: string, error: string): Promise { + const log = await db.integrationSyncLog.findUnique({ where: { id } }); + if (!log) { + this.logger.warn(`Sync log not found: ${id}`); + return; + } + + const now = new Date(); + const durationMs = log.startedAt + ? now.getTime() - log.startedAt.getTime() + : null; + + await db.integrationSyncLog.update({ + where: { id }, + data: { + status: 'failed', + completedAt: now, + durationMs, + error, + }, + }); + } +} diff --git a/packages/db/prisma/migrations/20260316194210_add_integration_sync_log/migration.sql b/packages/db/prisma/migrations/20260316194210_add_integration_sync_log/migration.sql new file mode 100644 index 0000000000..07fab07ba3 --- /dev/null +++ b/packages/db/prisma/migrations/20260316194210_add_integration_sync_log/migration.sql @@ -0,0 +1,40 @@ +-- CreateEnum +CREATE TYPE "IntegrationSyncLogStatus" AS ENUM ('pending', 'running', 'success', 'failed'); + +-- CreateTable +CREATE TABLE "IntegrationSyncLog" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isl'::text), + "connectionId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "status" "IntegrationSyncLogStatus" NOT NULL DEFAULT 'pending', + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "durationMs" INTEGER, + "result" JSONB, + "error" TEXT, + "triggeredBy" TEXT, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IntegrationSyncLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IntegrationSyncLog_connectionId_idx" ON "IntegrationSyncLog"("connectionId"); + +-- CreateIndex +CREATE INDEX "IntegrationSyncLog_organizationId_idx" ON "IntegrationSyncLog"("organizationId"); + +-- CreateIndex +CREATE INDEX "IntegrationSyncLog_provider_idx" ON "IntegrationSyncLog"("provider"); + +-- CreateIndex +CREATE INDEX "IntegrationSyncLog_createdAt_idx" ON "IntegrationSyncLog"("createdAt"); + +-- AddForeignKey +ALTER TABLE "IntegrationSyncLog" ADD CONSTRAINT "IntegrationSyncLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "IntegrationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IntegrationSyncLog" ADD CONSTRAINT "IntegrationSyncLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/integration-platform.prisma b/packages/db/prisma/schema/integration-platform.prisma index 509c6074c8..615be0e361 100644 --- a/packages/db/prisma/schema/integration-platform.prisma +++ b/packages/db/prisma/schema/integration-platform.prisma @@ -72,6 +72,7 @@ model IntegrationConnection { runs IntegrationRun[] findings IntegrationPlatformFinding[] checkRuns IntegrationCheckRun[] + syncLogs IntegrationSyncLog[] @@index([organizationId]) @@index([providerId]) diff --git a/packages/db/prisma/schema/integration-sync-log.prisma b/packages/db/prisma/schema/integration-sync-log.prisma new file mode 100644 index 0000000000..2558dca999 --- /dev/null +++ b/packages/db/prisma/schema/integration-sync-log.prisma @@ -0,0 +1,45 @@ +// ===== Integration Sync Log ===== +// Generic audit trail for integration sync operations (employee sync, role discovery, etc.) + +model IntegrationSyncLog { + id String @id @default(dbgenerated("generate_prefixed_cuid('isl'::text)")) + connectionId String + connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + /// Provider slug (e.g., "ramp", "google-workspace", "rippling", "jumpcloud") + provider String + /// Event type (e.g., "employee_sync", "role_discovery", "role_mapping_save") + eventType String + /// Execution status + status IntegrationSyncLogStatus @default(pending) + /// When the operation started executing + startedAt DateTime? + /// When the operation completed (success or failure) + completedAt DateTime? + /// Duration in milliseconds + durationMs Int? + /// Flexible result payload (e.g., { imported, deactivated, reactivated, skipped, errors }) + result Json? + /// Error message if failed + error String? + /// How the sync was triggered: "manual", "scheduled", "api" + triggeredBy String? + /// User who triggered the sync (null for automated/cron) + userId String? + + createdAt DateTime @default(now()) + + @@index([connectionId]) + @@index([organizationId]) + @@index([provider]) + @@index([createdAt]) +} + +enum IntegrationSyncLogStatus { + pending + running + success + failed +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index dd10835685..aac979d581 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -58,6 +58,7 @@ model Organization { // Integration Platform integrationConnections IntegrationConnection[] integrationOAuthApps IntegrationOAuthApp[] + integrationSyncLogs IntegrationSyncLog[] // Pentest Subscription pentestSubscription PentestSubscription? From 5d373ec4add70fb0b801761dd5657755acc271b5 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 09:22:06 -0400 Subject: [PATCH 06/15] feat(integration-platform): validate connection existence in role mapping endpoints - Added validation to check if the connection exists and belongs to the correct organization in both role mapping and get role mapping methods. - Enhanced error handling to return appropriate HTTP status codes for missing connections. --- .../controllers/ramp-role-mapping.controller.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts index f278dcd369..5ad69f43ac 100644 --- a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -50,6 +50,11 @@ export class RampRoleMappingController { throw new HttpException('connectionId is required', HttpStatus.BAD_REQUEST); } + const connection = await this.connectionRepository.findById(connectionId); + if (!connection || connection.organizationId !== organizationId) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + const shouldRefresh = refresh === 'true'; let discoveredRoles: Array<{ role: string; userCount: number }>; @@ -176,12 +181,18 @@ export class RampRoleMappingController { @Get('role-mapping') @RequirePermission('integration', 'read') async getRoleMapping( + @OrganizationId() organizationId: string, @Query('connectionId') connectionId: string, ) { if (!connectionId) { throw new HttpException('connectionId is required', HttpStatus.BAD_REQUEST); } + const connection = await this.connectionRepository.findById(connectionId); + if (!connection || connection.organizationId !== organizationId) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + const mapping = await this.roleMappingService.getSavedMapping(connectionId); return { mapping }; } From 433a1be746e6c913b03ccbfe1800dec1450f1965 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 09:23:56 -0400 Subject: [PATCH 07/15] feat(integration-platform): enhance sync logging for role mapping configuration - Updated SyncController to log when role mapping is not configured during manual sync. - Modified response structure to include logging of sync results when no Ramp roles are found. - Ensured that sync operations provide detailed logging for better traceability. --- .../integration-platform/controllers/sync.controller.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index fbda0c3247..1824d4b2b6 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1272,6 +1272,10 @@ export class SyncController { if (!isAutomatedSync) { // Manual sync — prompt user to configure mapping via UI + await this.syncLoggerService.completeLog(logId, { + requiresRoleMapping: true, + message: 'Role mapping is not configured', + }); return { success: false, requiresRoleMapping: true, @@ -1293,8 +1297,7 @@ export class SyncController { this.logger.warn( 'No Ramp roles found to auto-generate mapping', ); - return { - success: true, + const emptyResult = { totalFound: 0, imported: 0, skipped: 0, @@ -1303,6 +1306,8 @@ export class SyncController { errors: 0, details: [], }; + await this.syncLoggerService.completeLog(logId, emptyResult); + return { success: true, ...emptyResult }; } const defaultEntries = From 1e24263faba624bcdb8b398e41860266194b3887 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 09:42:50 -0400 Subject: [PATCH 08/15] refactor(integration-platform): simplify role creation logic in RampRoleMappingService - Removed the role count check when ensuring custom roles exist, streamlining the role creation process. - Updated documentation to clarify the purpose of the ensureCustomRolesExist method. --- .../services/ramp-role-mapping.service.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts index 1ad8262b64..f2a05cf600 100644 --- a/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts +++ b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts @@ -79,7 +79,7 @@ export class RampRoleMappingService { /** * Ensure all custom roles in the mapping exist in the database. - * Creates missing ones. Returns a map of compRole name → role name (for assignment). + * Creates missing ones. */ async ensureCustomRolesExist( organizationId: string, @@ -99,18 +99,6 @@ export class RampRoleMappingService { continue; } - // Check max roles limit - const roleCount = await db.organizationRole.count({ - where: { organizationId }, - }); - - if (roleCount >= 20) { - this.logger.warn( - `Cannot create role "${entry.compRole}" — max 20 custom roles reached`, - ); - continue; - } - await db.organizationRole.create({ data: { name: entry.compRole, From 6105a674fe3eb78533c9bf690de9d95f73d165de Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 09:53:46 -0400 Subject: [PATCH 09/15] fix(integration-platform): improve error handling in RampRoleMappingContent - Enhanced error handling in fetchRoles and handleRefresh functions to ensure error messages are displayed appropriately. - Added error logging to provide better traceability during role fetching and refreshing operations. --- .../components/RampRoleMappingContent.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx index cdea022d99..fad2b4f12c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx @@ -61,25 +61,36 @@ export function RampRoleMappingContent({ setMapping(initialMapping); savedMappingRef.current = JSON.stringify(initialMapping); } - } catch { + } catch (error) { toast.error('Failed to load Ramp roles'); + throw error; } }; useEffect(() => { const load = async () => { setIsLoading(true); - await fetchRoles(); - setIsLoading(false); + try { + await fetchRoles(); + } catch { + // error toast already shown by fetchRoles + } finally { + setIsLoading(false); + } }; load(); }, [organizationId, connectionId]); const handleRefresh = async () => { setIsRefreshing(true); - await fetchRoles(true); - setIsRefreshing(false); - toast.success('Roles refreshed from Ramp'); + try { + await fetchRoles(true); + toast.success('Roles refreshed from Ramp'); + } catch { + // fetchRoles already shows error toast + } finally { + setIsRefreshing(false); + } }; const handleEntryChange = (index: number, updated: RoleMappingEntry) => { From 7e52ec41a1789cc1a1ba042b397811d1c2db4809 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 10:20:03 -0400 Subject: [PATCH 10/15] feat(integration-platform): enhance role mapping persistence logic - Updated RampRoleMappingController to preserve existing role mappings when saving discovered roles. - Introduced saveDiscoveredRoles method in RampRoleMappingService to save only discovered roles without altering the role_mapping field. - Improved handling of existing mappings to ensure data integrity during role updates. --- .../ramp-role-mapping.controller.ts | 16 ++++++---- .../services/ramp-role-mapping.service.ts | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts index 5ad69f43ac..815897b230 100644 --- a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -88,13 +88,17 @@ export class RampRoleMappingController { .map(([role, userCount]) => ({ role, userCount })) .sort((a, b) => b.userCount - a.userCount); - // Cache the discovered roles + // Cache the discovered roles (preserve existing mapping if any) const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); - await this.roleMappingService.saveMapping( - connectionId, - existingMapping ?? [], - discoveredRoles, - ); + if (existingMapping) { + await this.roleMappingService.saveMapping( + connectionId, + existingMapping, + discoveredRoles, + ); + } else { + await this.roleMappingService.saveDiscoveredRoles(connectionId, discoveredRoles); + } await this.syncLoggerService.completeLog(logId, { rolesDiscovered: discoveredRoles.length, diff --git a/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts index f2a05cf600..7e82ed05f5 100644 --- a/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts +++ b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts @@ -183,6 +183,36 @@ export class RampRoleMappingService { }); } + /** + * Save only discovered roles without touching the role_mapping field + */ + async saveDiscoveredRoles( + connectionId: string, + discoveredRoles: Array<{ role: string; userCount: number }>, + ): Promise { + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { variables: true }, + }); + + const existingVariables = (connection?.variables ?? {}) as Record< + string, + unknown + >; + + const updatedVariables: Record = { + ...existingVariables, + discovered_roles: discoveredRoles, + }; + + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { + variables: updatedVariables as unknown as Prisma.InputJsonValue, + }, + }); + } + /** * Get cached discovered roles from connection variables */ From 638e670aa8b82b749aed5deaade5a8def58be2c4 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 17 Mar 2026 10:39:35 -0400 Subject: [PATCH 11/15] feat(integration-platform): implement RampApiService for user management - Introduced RampApiService to handle access token retrieval and user fetching from the Ramp API. - Refactored RampRoleMappingController and SyncController to utilize RampApiService for improved user management. - Removed redundant access token and user fetching logic from controllers, enhancing code maintainability. --- .../ramp-role-mapping.controller.ts | 109 +--------- .../controllers/sync.controller.ts | 202 +++--------------- .../integration-platform.module.ts | 2 + .../services/ramp-api.service.ts | 188 ++++++++++++++++ .../people/all/hooks/useEmployeeSync.ts | 8 +- 5 files changed, 231 insertions(+), 278 deletions(-) create mode 100644 apps/api/src/integration-platform/services/ramp-api.service.ts diff --git a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts index 815897b230..60977367eb 100644 --- a/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -15,16 +15,10 @@ import { RequirePermission } from '../../auth/require-permission.decorator'; import { OrganizationId } from '../../auth/auth-context.decorator'; 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 { RampRoleMappingService } from '../services/ramp-role-mapping.service'; import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; -import { - getManifest, - type RampUser, - type RampUsersResponse, - type RoleMappingEntry, -} from '@trycompai/integration-platform'; +import { RampApiService } from '../services/ramp-api.service'; +import { type RoleMappingEntry } from '@trycompai/integration-platform'; @Controller({ path: 'integrations/sync/ramp', version: '1' }) @ApiTags('Integrations') @@ -33,10 +27,9 @@ import { export class RampRoleMappingController { constructor( private readonly connectionRepository: ConnectionRepository, - private readonly credentialVaultService: CredentialVaultService, - private readonly oauthCredentialsService: OAuthCredentialsService, private readonly roleMappingService: RampRoleMappingService, private readonly syncLoggerService: IntegrationSyncLoggerService, + private readonly rampApiService: RampApiService, ) {} @Post('discover-roles') @@ -75,8 +68,8 @@ export class RampRoleMappingController { }); try { - const accessToken = await this.getAccessToken(connectionId, organizationId); - const users = await this.fetchAllRampUsers(accessToken); + const accessToken = await this.rampApiService.getAccessToken(connectionId, organizationId); + const users = await this.rampApiService.fetchUsers(accessToken); const roleCounts = new Map(); for (const user of users) { @@ -200,96 +193,4 @@ export class RampRoleMappingController { const mapping = await this.roleMappingService.getSavedMapping(connectionId); return { mapping }; } - - private async getAccessToken( - connectionId: string, - organizationId: string, - ): Promise { - let credentials = - await this.credentialVaultService.getDecryptedCredentials(connectionId); - - if (!credentials?.access_token) { - throw new HttpException( - 'No valid credentials. Please reconnect.', - HttpStatus.UNAUTHORIZED, - ); - } - - const manifest = getManifest('ramp'); - const oauthConfig = - manifest?.auth.type === 'oauth2' ? manifest.auth.config : null; - - if (oauthConfig?.supportsRefreshToken && credentials.refresh_token) { - try { - const oauthCreds = await this.oauthCredentialsService.getCredentials( - 'ramp', - organizationId, - ); - if (oauthCreds) { - const newToken = await this.credentialVaultService.refreshOAuthTokens( - connectionId, - { - tokenUrl: oauthConfig.tokenUrl, - refreshUrl: oauthConfig.refreshUrl, - clientId: oauthCreds.clientId, - clientSecret: oauthCreds.clientSecret, - clientAuthMethod: oauthConfig.clientAuthMethod, - }, - ); - if (newToken) { - credentials = - await this.credentialVaultService.getDecryptedCredentials(connectionId); - } - } - } catch { - // Try with existing token - } - } - - if (!credentials?.access_token) { - throw new HttpException( - 'No valid credentials. Please reconnect.', - HttpStatus.UNAUTHORIZED, - ); - } - - const token = credentials.access_token; - return Array.isArray(token) ? token[0] : token; - } - - private async fetchAllRampUsers(accessToken: string): Promise { - const users: RampUser[] = []; - let nextUrl: string | null = null; - - do { - const url = nextUrl - ? new URL(nextUrl) - : new URL('https://demo-api.ramp.com/developer/v1/users'); - if (!nextUrl) { - url.searchParams.set('page_size', '100'); - } - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new HttpException( - 'Failed to fetch users from Ramp', - HttpStatus.BAD_GATEWAY, - ); - } - - const data: RampUsersResponse = await response.json(); - if (data.data?.length) { - users.push(...data.data); - } - nextUrl = data.page?.next ?? null; - } while (nextUrl); - - return users; - } } diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 1824d4b2b6..07ecb378b7 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -32,6 +32,7 @@ import { } from '@trycompai/integration-platform'; import { RampRoleMappingService } from '../services/ramp-role-mapping.service'; import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; +import { RampApiService } from '../services/ramp-api.service'; interface GoogleWorkspaceUser { id: string; @@ -112,6 +113,7 @@ export class SyncController { private readonly oauthCredentialsService: OAuthCredentialsService, private readonly rampRoleMappingService: RampRoleMappingService, private readonly syncLoggerService: IntegrationSyncLoggerService, + private readonly rampApiService: RampApiService, ) {} /** @@ -1055,169 +1057,10 @@ export class SyncController { connection: { variables: unknown }, logId: string, ) { - const manifest = getManifest('ramp'); - if (!manifest) { - throw new HttpException( - 'Ramp manifest not found', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - let credentials = - await this.credentialVaultService.getDecryptedCredentials(connectionId); - - if (!credentials?.access_token) { - throw new HttpException( - 'No valid credentials found. Please reconnect the integration.', - HttpStatus.UNAUTHORIZED, - ); - } - - const oauthConfig = - manifest.auth.type === 'oauth2' ? manifest.auth.config : null; - - if (oauthConfig?.supportsRefreshToken && credentials.refresh_token) { - try { - const oauthCredentials = - await this.oauthCredentialsService.getCredentials( - 'ramp', - organizationId, - ); - - if (oauthCredentials) { - const newToken = await this.credentialVaultService.refreshOAuthTokens( - connectionId, - { - tokenUrl: oauthConfig.tokenUrl, - refreshUrl: oauthConfig.refreshUrl, - clientId: oauthCredentials.clientId, - clientSecret: oauthCredentials.clientSecret, - clientAuthMethod: oauthConfig.clientAuthMethod, - }, - ); - if (newToken) { - credentials = - await this.credentialVaultService.getDecryptedCredentials( - connectionId, - ); - if (!credentials?.access_token) { - throw new Error('Failed to get refreshed credentials'); - } - this.logger.log('Successfully refreshed Ramp OAuth token'); - } - } - } catch (refreshError) { - this.logger.warn( - `Token refresh failed, trying with existing token: ${refreshError}`, - ); - } - } + const accessToken = await this.rampApiService.getAccessToken(connectionId, organizationId); - const accessToken = credentials?.access_token; - if (!accessToken) { - throw new HttpException( - 'No valid credentials found. Please reconnect the integration.', - HttpStatus.UNAUTHORIZED, - ); - } - - const MAX_RETRIES = 3; - - const fetchRampUsers = async (status?: RampUserStatus) => { - const users: RampUser[] = []; - let nextUrl: string | null = null; - - try { - do { - const url = nextUrl - ? new URL(nextUrl) - : new URL('https://demo-api.ramp.com/developer/v1/users'); - if (!nextUrl) { - url.searchParams.set('page_size', '100'); - if (status) { - url.searchParams.set('status', status); - } - } - - 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) { - throw new HttpException( - 'Ramp credentials expired. Please reconnect.', - HttpStatus.UNAUTHORIZED, - ); - } - if (response.status === 403) { - throw new HttpException( - 'Ramp access denied. Ensure users:read scope is granted.', - HttpStatus.FORBIDDEN, - ); - } - - const errorText = await response.text(); - this.logger.error( - `Ramp API error: ${response.status} ${response.statusText} - ${errorText}`, - ); - throw new HttpException( - 'Failed to fetch users from Ramp', - HttpStatus.BAD_GATEWAY, - ); - } - - const data: RampUsersResponse = await response.json(); - if (data.data?.length) { - users.push(...data.data); - } - - nextUrl = data.page?.next ?? null; - } while (nextUrl); - } catch (error) { - if (error instanceof HttpException) throw error; - this.logger.error(`Error fetching Ramp users: ${error}`); - throw new HttpException( - 'Failed to fetch users from Ramp', - HttpStatus.BAD_GATEWAY, - ); - } - - return users; - }; - - const baseUsers = await fetchRampUsers(); - const suspendedUsers = await fetchRampUsers('USER_SUSPENDED'); + const baseUsers = await this.rampApiService.fetchUsers(accessToken); + const suspendedUsers = await this.rampApiService.fetchUsers(accessToken, 'USER_SUSPENDED'); const users = [...baseUsers, ...suspendedUsers]; // Filter out non-syncable statuses (pending invites, onboarding, expired) @@ -1371,6 +1214,7 @@ export class SyncController { const results = { imported: 0, + updated: 0, skipped: 0, deactivated: 0, reactivated: 0, @@ -1379,6 +1223,7 @@ export class SyncController { email: string; status: | 'imported' + | 'updated' | 'skipped' | 'deactivated' | 'reactivated' @@ -1466,15 +1311,21 @@ export class SyncController { where: { id: existingMember.id }, data: updateData, }); - results.skipped++; - results.details.push({ - email: normalizedEmail, - status: 'skipped', - reason: - updateData.role - ? `Role updated to ${mappedRole}` - : 'Already a member', - }); + if (updateData.role) { + results.updated++; + results.details.push({ + email: normalizedEmail, + status: 'updated', + reason: `Role updated to ${mappedRole}`, + }); + } else { + results.skipped++; + results.details.push({ + email: normalizedEmail, + status: 'skipped', + reason: 'Already a member (external ID backfilled)', + }); + } } else { results.skipped++; results.details.push({ @@ -1605,12 +1456,18 @@ export class SyncController { ); } catch (error) { this.logger.error(`Error deactivating member: ${error}`); + results.errors++; + results.details.push({ + email: memberEmail, + status: 'error', + reason: `Failed to deactivate: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); } } } this.logger.log( - `Ramp sync complete: ${results.imported} imported, ${results.reactivated} reactivated, ${results.deactivated} deactivated, ${results.skipped} skipped, ${results.errors} errors`, + `Ramp sync complete: ${results.imported} imported, ${results.updated} updated, ${results.reactivated} reactivated, ${results.deactivated} deactivated, ${results.skipped} skipped, ${results.errors} errors`, ); // Update lastSyncAt on the connection @@ -1629,6 +1486,7 @@ export class SyncController { await this.syncLoggerService.completeLog(logId, { imported: results.imported, + updated: results.updated, deactivated: results.deactivated, reactivated: results.reactivated, skipped: results.skipped, diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index 45806aa92a..1632dbfdd6 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -29,6 +29,7 @@ import { DynamicIntegrationRepository } from './repositories/dynamic-integration import { DynamicCheckRepository } from './repositories/dynamic-check.repository'; import { RampRoleMappingService } from './services/ramp-role-mapping.service'; import { IntegrationSyncLoggerService } from './services/integration-sync-logger.service'; +import { RampApiService } from './services/ramp-api.service'; @Module({ imports: [AuthModule], @@ -56,6 +57,7 @@ import { IntegrationSyncLoggerService } from './services/integration-sync-logger DynamicManifestLoaderService, RampRoleMappingService, IntegrationSyncLoggerService, + RampApiService, // Repositories ProviderRepository, ConnectionRepository, diff --git a/apps/api/src/integration-platform/services/ramp-api.service.ts b/apps/api/src/integration-platform/services/ramp-api.service.ts new file mode 100644 index 0000000000..d48a14627f --- /dev/null +++ b/apps/api/src/integration-platform/services/ramp-api.service.ts @@ -0,0 +1,188 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { CredentialVaultService } from './credential-vault.service'; +import { OAuthCredentialsService } from './oauth-credentials.service'; +import { + getManifest, + type RampUser, + type RampUserStatus, + type RampUsersResponse, +} from '@trycompai/integration-platform'; + +const MAX_RETRIES = 3; +const RAMP_USERS_URL = 'https://demo-api.ramp.com/developer/v1/users'; + +@Injectable() +export class RampApiService { + private readonly logger = new Logger(RampApiService.name); + + constructor( + private readonly credentialVaultService: CredentialVaultService, + private readonly oauthCredentialsService: OAuthCredentialsService, + ) {} + + /** + * Get a valid Ramp access token, refreshing if needed. + */ + async getAccessToken( + connectionId: string, + organizationId: string, + ): Promise { + let credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + + if (!credentials?.access_token) { + throw new HttpException( + 'No valid credentials found. Please reconnect the integration.', + HttpStatus.UNAUTHORIZED, + ); + } + + const manifest = getManifest('ramp'); + const oauthConfig = + manifest?.auth.type === 'oauth2' ? manifest.auth.config : null; + + if (oauthConfig?.supportsRefreshToken && credentials.refresh_token) { + try { + const oauthCreds = await this.oauthCredentialsService.getCredentials( + 'ramp', + organizationId, + ); + + if (oauthCreds) { + const newToken = await this.credentialVaultService.refreshOAuthTokens( + connectionId, + { + tokenUrl: oauthConfig.tokenUrl, + refreshUrl: oauthConfig.refreshUrl, + clientId: oauthCreds.clientId, + clientSecret: oauthCreds.clientSecret, + clientAuthMethod: oauthConfig.clientAuthMethod, + }, + ); + if (newToken) { + credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + if (!credentials?.access_token) { + throw new Error('Failed to get refreshed credentials'); + } + this.logger.log('Successfully refreshed Ramp OAuth token'); + } + } + } catch (refreshError) { + this.logger.warn( + `Token refresh failed, trying with existing token: ${refreshError}`, + ); + } + } + + if (!credentials?.access_token) { + throw new HttpException( + 'No valid credentials found. Please reconnect the integration.', + HttpStatus.UNAUTHORIZED, + ); + } + + const token = credentials.access_token; + return Array.isArray(token) ? token[0] : token; + } + + /** + * Fetch all Ramp users with pagination, retry, and rate-limit handling. + * Optionally filter by status. + */ + async fetchUsers( + accessToken: string, + status?: RampUserStatus, + ): Promise { + const users: RampUser[] = []; + let nextUrl: string | null = null; + + try { + do { + const url = nextUrl + ? new URL(nextUrl) + : new URL(RAMP_USERS_URL); + if (!nextUrl) { + url.searchParams.set('page_size', '100'); + if (status) { + url.searchParams.set('status', status); + } + } + + 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) { + throw new HttpException( + 'Ramp credentials expired. Please reconnect.', + HttpStatus.UNAUTHORIZED, + ); + } + if (response.status === 403) { + throw new HttpException( + 'Ramp access denied. Ensure users:read scope is granted.', + HttpStatus.FORBIDDEN, + ); + } + + const errorText = await response.text(); + this.logger.error( + `Ramp API error: ${response.status} ${response.statusText} - ${errorText}`, + ); + throw new HttpException( + 'Failed to fetch users from Ramp', + HttpStatus.BAD_GATEWAY, + ); + } + + const data: RampUsersResponse = await response.json(); + if (data.data?.length) { + users.push(...data.data); + } + + nextUrl = data.page?.next ?? null; + } while (nextUrl); + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Error fetching Ramp users: ${error}`); + throw new HttpException( + 'Failed to fetch users from Ramp', + HttpStatus.BAD_GATEWAY, + ); + } + + return users; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts index 2d0a7e6b8b..8dd6254620 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts @@ -14,6 +14,7 @@ interface SyncResult { requiresRoleMapping?: boolean; totalFound: number; imported: number; + updated: number; reactivated: number; deactivated: number; skipped: number; @@ -191,11 +192,14 @@ export const useEmployeeSync = ({ } if (response.data?.success) { - const { imported, reactivated, deactivated, skipped, errors } = response.data; + const { imported, updated, reactivated, deactivated, skipped, errors } = response.data; if (imported > 0) { toast.success(`Imported ${imported} new employee${imported > 1 ? 's' : ''}`); } + if (updated > 0) { + toast.success(`Updated roles for ${updated} employee${updated > 1 ? 's' : ''}`); + } if (reactivated > 0) { toast.success(`Reactivated ${reactivated} employee${reactivated > 1 ? 's' : ''}`); } @@ -204,7 +208,7 @@ export const useEmployeeSync = ({ `Deactivated ${deactivated} employee${deactivated > 1 ? 's' : ''} (no longer in ${config.name})`, ); } - if (imported === 0 && reactivated === 0 && deactivated === 0 && skipped > 0) { + if (imported === 0 && updated === 0 && reactivated === 0 && deactivated === 0 && skipped > 0) { toast.info('All employees are already synced'); } if (errors > 0) { From d5915cafa77b6eb82510369b0ee396be32f54784 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 17 Mar 2026 11:41:13 -0400 Subject: [PATCH 12/15] fix(api): make sure azure assessmet tile is not empty --- .../src/cloud-security/providers/azure-security.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/cloud-security/providers/azure-security.service.ts b/apps/api/src/cloud-security/providers/azure-security.service.ts index b9cbc8dd9e..4b87c25560 100644 --- a/apps/api/src/cloud-security/providers/azure-security.service.ts +++ b/apps/api/src/cloud-security/providers/azure-security.service.ts @@ -141,7 +141,10 @@ export class AzureSecurityService { for (const assessment of unhealthy.slice(0, 50)) { findings.push({ id: assessment.name, - title: assessment.properties.displayName, + title: + assessment.properties.displayName || + assessment.name || + 'Unhealthy security assessment', description: assessment.properties.metadata?.description || assessment.properties.status.description || From 7f873aa655f70fa6946c4b1f48337db989261992 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:01:46 -0400 Subject: [PATCH 13/15] feat: improve AI policy editor, better UI/UX and smarter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add per-hunk feedback design spec for suggested changes Co-Authored-By: Claude Opus 4.6 * feat: add per-hunk feedback UI to proposed changes card Add pencil icon button to the accept/reject pill that opens an inline text input for per-hunk AI feedback. Submitting sends contextual feedback through the existing chat, shows a shimmer loading state for the targeted hunk, and remaps decisions when the proposal changes. Co-Authored-By: Claude Opus 4.6 * feat: enhance policy editor with improved proposal handling and UI updates - Refactor `getLatestProposedPolicy` to `getLatestCompletedProposal` for better tracking of proposals across the entire conversation. - Update `PolicyContentManager` to utilize the new proposal fetching logic and manage proposal states more effectively. - Revamp `PolicyAiAssistant` UI to improve user interaction and feedback display. - Introduce confirmation for applying all changes in `ProposedChangesCard`, ensuring user intent is clear before executing bulk actions. - Update `.gitignore` to exclude new directories related to superpowers documentation. Co-Authored-By: Claude Opus 4.6 * fix: filter no-change hunks, merge skip blocks, and improve AI prompt - Filter out hunks with only whitespace-only changes from the diff view - Merge adjacent skip blocks and no-change hunks into a single "Show N unchanged lines" section - Count changes by reviewable sections (hunks) instead of individual lines - Reject All now marks all hunks as rejected instead of clearing to pending - Strengthen system prompt to preserve unchanged text verbatim Co-Authored-By: Claude Opus 4.6 * docs: add inline suggestion mode design spec Spec for replacing the separate ProposedChangesCard with inline TipTap decorations rendered directly in the policy editor. Co-Authored-By: Claude Opus 4.6 * feat(policy-editor): add inline suggestion types, position map, and range computation Add shared types for inline suggestions (DiffSegment, SuggestionRange, PositionMap), a position map builder that maps ProseMirror doc nodes to markdown line numbers, and a suggestion range computer that diffs markdown and produces positioned ranges with word-level diff segments. Includes comprehensive tests for both modules. Co-Authored-By: Claude Opus 4.6 * feat(editor): add SuggestionsExtension and thread props through editor chain Add TipTap ProseMirror plugin for inline suggestion decorations (modify, insert, delete) with gutter widgets for accept/reject/feedback actions. Extend Editor, AdvancedEditor, and PolicyEditor with additionalExtensions and onEditorReady props. Co-Authored-By: Claude Opus 4.6 * feat(policy-editor): integrate inline suggestions into PolicyDetails Replace ProposedChangesCard with inline editor suggestions using useSuggestions hook and SuggestionsTopBar. Remove diff/patch utility functions (createGitPatch, applySelectedHunks, convertContentToMarkdown) that are no longer needed. Co-Authored-By: Claude Opus 4.6 * refactor(suggestions): remove ProposedChangesCard and unused utilities Co-Authored-By: Claude Opus 4.6 * test(suggestions): add unit tests for useSuggestions hook Co-Authored-By: Claude Opus 4.6 * feat(suggestions): add remaining inline suggestion files Add useSuggestions hook, SuggestionsTopBar component, suggestion CSS styles, implementation plan doc, and fix PolicyDetails tests for new inline suggestions dependencies. Co-Authored-By: Claude Opus 4.6 * feat(suggestions): cursor-style navigation, bolder styling, render new sections as rich content - add prev/next navigation with keyboard shortcuts (F7/Shift+F7) - accept/reject current change from top bar (Cmd+Shift+Enter/Backspace) - focused change highlighting with extra emphasis - render new section widgets as proper DOM nodes (headings, lists, paragraphs) instead of raw markdown text - bolder red/green diff colors with underlines and strikethroughs - dark mode support for suggestion styles - add left padding to editor when suggestions active for gutter visibility Co-Authored-By: Claude Opus 4.6 * fix(suggestions): rewrite decoration building to walk document tree The previous approach used character offsets from diff segments as ProseMirror positions, but PM positions include structural tokens (open/close tags for blocks), so positions diverged for multi-block hunks. Decorations were created but not rendered. New approach: - Use doc.descendants() to find text blocks overlapping each range - Apply Decoration.node() to each block individually (always works) - Only attempt inline word-level diffs for single-block modifications - Render new section widgets using DOMSerializer for proper styling Co-Authored-By: Claude Opus 4.6 * fix(suggestions): lock editor during review, fix gutter placement - Make editor non-editable while suggestions are pending via ProseMirror editable prop — prevents content changes during review - Place gutter widgets inside the first text block of each range (pos + 1) so they render correctly for list items and nested blocks Co-Authored-By: Claude Opus 4.6 * fix(suggestions): remove left margin, show modify as green+red pair - Remove has-suggestions padding that pushed content right - Gutter buttons now inline (no absolute positioning/margin needed) - Modify hunks now show as green block (proposed) above red strikethrough block (original) — making it clear they're one change - Accepting replaces old (red) with new (green) - Remove unused suggestion-modified class Co-Authored-By: Claude Opus 4.6 * fix(suggestions): fix bold text in new sections, place widgets at top level - Reset font-weight to normal in .suggestion-new-section so only headings inside are bold, not body text and list items - Place insertion widgets and gutter buttons at depth-1 (doc child) position so they render outside nested structures (lists, blockquotes) and don't inherit parent element styling Co-Authored-By: Claude Opus 4.6 * fix(suggestions): merge overlapping ranges, remove inline gutter buttons - Merge overlapping/adjacent hunks that resolve to the same doc position into a single modify range — prevents duplicate button sets - Remove inline gutter buttons entirely — accept/reject is handled by the sticky top bar with navigation (cleaner, no layout issues) - Remove gutter CSS and callback plumbing (no longer needed) Co-Authored-By: Claude Opus 4.6 * chore(suggestions): clean up unused extension options Remove onAccept/onReject/onFeedback from SuggestionsExtensionOptions since accept/reject is handled entirely by the top bar. Co-Authored-By: Claude Opus 4.6 * feat(suggestions): auto-scroll to first change when suggestions arrive Co-Authored-By: Claude Opus 4.6 * feat(suggestions): enhance suggestions functionality with action bars and improved UI - Introduced action bars for accepting, rejecting, and providing feedback on suggestions. - Updated the suggestions top bar for better navigation and user interaction. - Enhanced styling for suggestion elements, including new CSS for action buttons and suggestion groups. - Improved the logic for scrolling to the first change and handling suggestion states. - Added support for showing/hiding toolbars based on suggestion activity. Co-Authored-By: Claude Opus 4.6 * feat(suggestions): enhance policy editor with feedback input and improved suggestion handling - Added a new SuggestionFeedbackInput component for inline feedback on suggestions. - Updated PolicyContentManager to handle editing and feedback submission for suggestions. - Enhanced useSuggestions hook to manage editing state and loading indicators. - Improved buildPositionMap and computeSuggestionRanges functions for better handling of list items and content normalization. - Updated styles for suggestion elements to improve user experience. Co-Authored-By: Claude Opus 4.6 * fix: resolve merge conflicts and clean up imports - Fix @comp/ui → @trycompai/ui import paths after merge - Remove duplicate @trycompai/email entries in tsconfig.json - Suppress Node.js deprecation warnings in dev script Co-Authored-By: Claude Opus 4.6 (1M context) * feat(policy-editor): enhance AI assistant and suggestions functionality - Adjusted media query for wide desktop view to 1280px. - Integrated stop functionality for AI assistant chat. - Improved layout for AI assistant and suggestions top bar. - Enhanced scrolling behavior for suggestion ranges. - Added history tracking for suggestion ranges to support undo functionality. - Updated markdown parsing to ensure accurate rendering of proposed content. This update aims to improve user experience in the policy editor by refining the AI assistant's interaction and enhancing the suggestions management system. * feat(policy-editor): improve suggestion system, chat UX, and RBAC - Rewrite buildPositionMap with two-pass approach for accurate line-to-position mapping - Extend delete ranges to next heading boundary for full section deletions - Merge adjacent diff hunks within 20 positions to prevent split deletions - Use single markdown parser (markdownToTipTapJSON) for both preview and accept - Move AI tools from direct DB access to API calls with cookie forwarding for RBAC - Strip previous proposePolicy content from conversation history to prevent reuse - Add multi-step tool calling with stopWhen(stepCountIs(5)) - Add thinking indicator, stop button, and tool loading states to chat UI - User messages render as right-aligned bubbles, compact tool cards - Auto-dismiss proposals on active→inactive transition - Undo support: Cmd+Z restores previous suggestion ranges - Lock editor during pending suggestions, restore original editable state - Side panel: sticky positioning, viewport-relative height, internal scroll - Editor bottom shadow instead of border for visual end marker - Comprehensive tests: 82 tests for position mapping and range computation, 27 tests for markdown parser, 55 tests for suggestion hook lifecycle Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 --- .gitignore | 2 + apps/app/next.config.ts | 1 + apps/app/package.json | 2 +- .../editor/components/PolicyDetails.test.tsx | 38 + .../editor/components/PolicyDetails.tsx | 379 ++--- .../ai/__tests__/markdown-utils.test.ts | 341 +++++ .../components/ai/policy-ai-assistant.tsx | 492 +++--- .../ai/suggestion-feedback-input.tsx | 84 ++ .../components/ai/suggestions-top-bar.tsx | 116 ++ .../hooks/__tests__/use-suggestions.test.ts | 1326 +++++++++++++++++ .../editor/hooks/use-suggestions.ts | 462 ++++++ .../lib/__tests__/build-position-map.test.ts | 312 ++++ .../compute-suggestion-ranges.test.ts | 381 +++++ .../editor/lib/build-position-map.ts | 129 ++ .../editor/lib/compute-suggestion-ranges.ts | 191 +++ .../[policyId]/editor/lib/suggestion-types.ts | 28 + .../editor/lib/test-helpers/editor-schema.ts | 75 + .../[policyId]/editor/tools/policy-tools.ts | 129 +- .../app/api/policies/[policyId]/chat/route.ts | 86 +- .../src/components/editor/advanced-editor.tsx | 12 +- .../src/components/editor/policy-editor.tsx | 30 +- apps/app/src/styles/editor.css | 360 +++++ .../2026-03-13-inline-suggestions-design.md | 381 +++++ .../2026-03-12-per-hunk-feedback-design.md | 62 + packages/ui/package.json | 4 + .../editor/extensions/suggestions.ts | 647 ++++++++ packages/ui/src/components/editor/index.tsx | 23 +- 27 files changed, 5681 insertions(+), 412 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/markdown-utils.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/suggestion-feedback-input.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/suggestions-top-bar.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/hooks/__tests__/use-suggestions.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/hooks/use-suggestions.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/build-position-map.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/compute-suggestion-ranges.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/build-position-map.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/compute-suggestion-ranges.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/suggestion-types.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/test-helpers/editor-schema.ts create mode 100644 docs/specs/2026-03-13-inline-suggestions-design.md create mode 100644 docs/superpowers/specs/2026-03-12-per-hunk-feedback-design.md create mode 100644 packages/ui/src/components/editor/extensions/suggestions.ts diff --git a/.gitignore b/.gitignore index 9d2ceaae80..4f5ba5dbea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules .pnp.js .idea/ +.superpowers/ +docs/superpowers/ # testing coverage diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index e3b0b1950e..af2ceb8a02 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -48,6 +48,7 @@ const config: NextConfig = { '@trycompai/db', '@prisma/client', '@trycompai/design-system', + '@trycompai/ui', '@carbon/icons-react', '@trycompai/company', ], diff --git a/apps/app/package.json b/apps/app/package.json index 827d19a602..ccc4a5d4af 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -189,7 +189,7 @@ "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", - "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"trigger dev\"", + "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000\" \"NODE_OPTIONS='--no-deprecation' trigger dev\"", "lint": "eslint . && prettier --check .", "prebuild": "bun run db:generate", "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.test.tsx index bb4c0eb768..f276e57af8 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.test.tsx @@ -8,6 +8,21 @@ import { } from '@/test-utils/mocks/permissions'; import { PolicyStatus } from '@db'; +// Mock matchMedia for useMediaQuery +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + // Mock usePermissions vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -86,6 +101,27 @@ vi.mock('diff', () => ({ // Mock editor CSS import vi.mock('@/styles/editor.css', () => ({})); +// Mock useSuggestions hook +vi.mock('../hooks/use-suggestions', () => ({ + useSuggestions: () => ({ + ranges: [], + activeCount: 0, + totalCount: 0, + currentIndex: 0, + accept: vi.fn(), + reject: vi.fn(), + acceptCurrent: vi.fn(), + rejectCurrent: vi.fn(), + acceptAll: vi.fn(), + rejectAll: vi.fn(), + dismissAll: vi.fn(), + giveFeedback: vi.fn(), + goToNext: vi.fn(), + goToPrev: vi.fn(), + isActive: false, + }), +})); + // Mock PolicyEditor vi.mock('@/components/editor/policy-editor', () => ({ PolicyEditor: ({ @@ -98,6 +134,8 @@ vi.mock('@/components/editor/policy-editor', () => ({ // Mock editor utils vi.mock('@trycompai/ui/editor', () => ({ validateAndFixTipTapContent: (content: unknown) => ({ content }), + SuggestionsExtension: { configure: () => ({}) }, + suggestionsPluginKey: { key: 'suggestions$' }, })); // Mock DiffViewer diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 048eed46bf..2c0bfe9e41 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -4,6 +4,7 @@ import { SelectAssignee } from '@/components/SelectAssignee'; import { PolicyEditor } from '@/components/editor/policy-editor'; import { useChat } from '@ai-sdk/react'; import { Badge } from '@trycompai/ui/badge'; +import { useMediaQuery } from '@trycompai/ui/hooks'; import { Dialog, DialogContent, @@ -12,16 +13,15 @@ import { DialogHeader, DialogTitle, } from '@trycompai/ui/dialog'; -import { DiffViewer } from '@trycompai/ui/diff-viewer'; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from '@trycompai/ui/dropdown-menu'; -import { validateAndFixTipTapContent } from '@trycompai/ui/editor'; +import { validateAndFixTipTapContent, SuggestionsExtension } from '@trycompai/ui/editor'; import { PolicyStatus, type Member, type PolicyDisplayFormat, type PolicyVersion, type User } from '@db'; -import type { JSONContent } from '@tiptap/react'; +import type { JSONContent, Editor as TipTapEditor } from '@tiptap/react'; import { AlertDialog, AlertDialogAction, @@ -41,13 +41,12 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; -import { Checkmark, Close, MagicWand } from '@trycompai/design-system/icons'; +import { Close, MagicWand } from '@trycompai/design-system/icons'; import { DefaultChatTransport } from 'ai'; import { format } from 'date-fns'; -import { structuredPatch } from 'diff'; import { ArrowDownUp, ChevronDown, ChevronLeft, ChevronRight, FileText, Trash2, Upload } from 'lucide-react'; import { useParams } from 'next/navigation'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { usePolicy } from '../../hooks/usePolicy'; import { usePolicyVersions } from '../../hooks/usePolicyVersions'; @@ -55,8 +54,12 @@ import { usePermissions } from '@/hooks/use-permissions'; import { PdfViewer } from '../../components/PdfViewer'; import { PublishVersionDialog } from '../../components/PublishVersionDialog'; import type { PolicyChatUIMessage } from '../types'; -import { markdownToTipTapJSON } from './ai/markdown-utils'; import { PolicyAiAssistant } from './ai/policy-ai-assistant'; +import { useSuggestions } from '../hooks/use-suggestions'; +import { buildPositionMap } from '../lib/build-position-map'; +import { markdownToTipTapJSON } from './ai/markdown-utils'; + +import { SuggestionsTopBar } from './ai/suggestions-top-bar'; type PolicyVersionWithPublisher = PolicyVersion & { publishedBy: (Member & { user: User }) | null; @@ -87,29 +90,35 @@ interface LatestProposal { reviewHint: string; } -function getLatestProposedPolicy(messages: PolicyChatUIMessage[]): LatestProposal | null { - const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistantMessage?.parts) return null; - +/** + * Scan ALL assistant messages for the latest completed proposePolicy tool call. + * This ensures the card stays visible even when a new streaming response starts + * (the previous completed proposal lives on an earlier message). + */ +function getLatestCompletedProposal(messages: PolicyChatUIMessage[]): LatestProposal | null { let latest: LatestProposal | null = null; - lastAssistantMessage.parts.forEach((part, index) => { - if (part.type !== 'tool-proposePolicy') return; - if (part.state === 'input-streaming' || part.state === 'output-error') return; - const input = part.input; - if (!input?.content) return; - - latest = { - key: `${lastAssistantMessage.id}:${index}`, - content: input.content, - summary: input.summary ?? 'Proposing policy changes', - title: input.title ?? input.summary ?? 'Policy updates ready for your review', - detail: - input.detail ?? - 'I have prepared an updated version of this policy based on your instructions.', - reviewHint: input.reviewHint ?? 'Review the proposed changes below before applying them.', - }; - }); + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.parts) continue; + for (let index = 0; index < msg.parts.length; index++) { + const part = msg.parts[index]; + if (!part || part.type !== 'tool-proposePolicy') continue; + if (part.state === 'input-streaming' || part.state === 'output-error') continue; + const input = part.input; + if (!input?.content) continue; + + latest = { + key: `${msg.id}:${index}`, + content: input.content, + summary: input.summary ?? 'Proposing policy changes', + title: input.title ?? input.summary ?? 'Policy updates ready for your review', + detail: + input.detail ?? + 'I have prepared an updated version of this policy based on your instructions.', + reviewHint: input.reviewHint ?? 'Review the proposed changes below before applying them.', + }; + } + } return latest; } @@ -182,6 +191,8 @@ export function PolicyContentManager({ const canDeletePolicy = hasPermission('policy', 'delete'); const [showAiAssistant, setShowAiAssistant] = useState(false); + const isWideDesktop = useMediaQuery('(min-width: 1280px)'); + const isDesktop = useMediaQuery('(min-width: 1024px)'); const [editorKey, setEditorKey] = useState(0); const [activeTab, setActiveTab] = useState(displayFormat); const previousTabRef = useRef(displayFormat); @@ -193,8 +204,38 @@ export function PolicyContentManager({ }); const [dismissedProposalKey, setDismissedProposalKey] = useState(null); - const [isApplying, setIsApplying] = useState(false); + const [editorInstance, setEditorInstance] = useState(null); const [chatErrorMessage, setChatErrorMessage] = useState(null); + + // Stable callback refs so the extension doesn't need to be recreated + // when suggestion handlers change + const suggestionCallbacksRef = useRef<{ + onAccept: (id: string) => void; + onReject: (id: string) => void; + onEditClick: (id: string) => void; + onFeedbackSubmit: (id: string, feedback: string) => void; + onFeedbackCancel: () => void; + }>({ + onAccept: () => {}, + onReject: () => {}, + onEditClick: () => {}, + onFeedbackSubmit: () => {}, + onFeedbackCancel: () => {}, + }); + + const suggestionsExtension = useMemo( + () => + SuggestionsExtension.configure({ + onAccept: (id: string) => suggestionCallbacksRef.current.onAccept(id), + onReject: (id: string) => suggestionCallbacksRef.current.onReject(id), + onEditClick: (id: string) => suggestionCallbacksRef.current.onEditClick(id), + onFeedbackSubmit: (id: string, feedback: string) => suggestionCallbacksRef.current.onFeedbackSubmit(id, feedback), + onFeedbackCancel: () => suggestionCallbacksRef.current.onFeedbackCancel(), + markdownToJSON: markdownToTipTapJSON, + }), + [], + ); + const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [localHasChanges, setLocalHasChanges] = useState(hasUnpublishedChanges); @@ -427,6 +468,7 @@ export function PolicyContentManager({ messages, status, sendMessage: baseSendMessage, + stop: stopChat, } = useChat({ transport: new DefaultChatTransport({ api: `/api/policies/${policyId}/chat`, @@ -439,30 +481,35 @@ export function PolicyContentManager({ const sendMessage = (payload: { text: string }) => { setChatErrorMessage(null); - baseSendMessage(payload); + // Send current editor content so the AI sees the latest state, + // not stale DB content (e.g. after accepting changes) + const currentContent = editorInstance + ? buildPositionMap(editorInstance.state.doc).markdown + : ''; + baseSendMessage(payload, { body: { currentContent } }); }; - const latestProposal = useMemo(() => getLatestProposedPolicy(messages), [messages]); - - const activeProposal = - latestProposal && latestProposal.key !== dismissedProposalKey ? latestProposal : null; + // ── Proposal state management ────────────────────────────────────── + // Scan ALL assistant messages for the latest completed proposePolicy tool call. + // Unlike before, this doesn't only check the last assistant message — it finds + // the most recent completed proposal across the entire conversation so that + // starting a new streaming response doesn't cause the card to vanish. - const proposedPolicyMarkdown = activeProposal?.content ?? null; - - const hasPendingProposal = useMemo( - () => - messages.some( - (m) => - m.role === 'assistant' && - m.parts?.some( - (part) => - part.type === 'tool-proposePolicy' && - (part.state === 'input-streaming' || part.state === 'input-available'), - ), - ), + const latestCompletedProposal = useMemo( + () => getLatestCompletedProposal(messages), [messages], ); + // The last fully-completed, non-dismissed proposal the user can act on. + // Clear dismissedProposalKey when a new proposal arrives so it's not blocked. + const activeProposal = useMemo(() => { + if (!latestCompletedProposal) return null; + if (latestCompletedProposal.key === dismissedProposalKey) return null; + return latestCompletedProposal; + }, [latestCompletedProposal, dismissedProposalKey]); + + const proposedPolicyMarkdown = activeProposal?.content ?? null; + const handleSwitchFormat = async (format: string) => { previousTabRef.current = activeTab; // Only persist the preference if the user can update the policy @@ -479,41 +526,70 @@ export function PolicyContentManager({ } }; - const currentPolicyMarkdown = useMemo( - () => convertContentToMarkdown(currentContent), - [currentContent], - ); + const FEEDBACK_MARKER = '___hunk_feedback___'; - const diffPatch = useMemo(() => { - if (!proposedPolicyMarkdown) return null; - return createGitPatch('Proposed Changes', currentPolicyMarkdown, proposedPolicyMarkdown); - }, [currentPolicyMarkdown, proposedPolicyMarkdown]); + const suggestions = useSuggestions({ + editor: editorInstance, + proposedMarkdown: proposedPolicyMarkdown, + onFeedback: (rangeId, feedback) => { + const range = suggestions.ranges.find((r) => r.id === rangeId); + if (!range) return; - async function applyProposedChanges() { - if (!activeProposal || !viewingVersion) return; + sendMessage({ + text: `For the section that says:\n"""\n${range.proposedText}\n"""\n\nFeedback: ${feedback}\n\nApply this feedback ONLY to the section above. Do not change any other sections.\n${FEEDBACK_MARKER}`, + }); + }, + }); - // Don't allow applying changes to read-only versions - if (isVersionReadOnly) { - toast.error('Cannot modify a published or pending version. Create a new version first.'); - return; + // Auto-dismiss proposal when ranges transition from active → inactive + const wasActiveRef = useRef(false); + useEffect(() => { + if (suggestions.isActive) { + wasActiveRef.current = true; + } else if (wasActiveRef.current && activeProposal) { + // Was active, now inactive — all ranges resolved + wasActiveRef.current = false; + setDismissedProposalKey(activeProposal.key); } + }, [suggestions.isActive, activeProposal]); - const { content, key } = activeProposal; + // Reset loading state when AI finishes responding or errors + useEffect(() => { + if (status === 'ready' || status === 'error') { + suggestions.resetLoading(); + } + }, [status, suggestions.resetLoading]); + + // Wire suggestion callbacks via refs (avoids recreating the extension) + suggestionCallbacksRef.current = { + onAccept: suggestions.accept, + onReject: suggestions.reject, + onEditClick: suggestions.startEditing, + onFeedbackSubmit: suggestions.giveFeedback, + onFeedbackCancel: suggestions.cancelEditing, + }; - setIsApplying(true); - try { - const jsonContent = markdownToTipTapJSON(content); - await updateVersionContent(viewingVersion, jsonContent); - setCurrentContent(jsonContent); - setEditorKey((prev) => prev + 1); - setDismissedProposalKey(key); - toast.success('Policy updated with AI suggestions'); - } catch { - toast.error('Failed to apply changes'); - } finally { - setIsApplying(false); + // Filter out per-hunk feedback messages (and their AI responses) from chat display + const displayMessages = useMemo(() => { + const result: typeof messages = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (!msg) continue; + const isFeedbackMsg = + msg.role === 'user' && + msg.parts?.some( + (part) => part.type === 'text' && part.text.includes(FEEDBACK_MARKER), + ); + if (isFeedbackMsg) { + // Skip this user message and the following assistant response + const next = messages[i + 1]; + if (next?.role === 'assistant') i++; + continue; + } + result.push(msg); } - } + return result; + }, [messages]); // Track local changes made in editor (after save) const handleContentSaved = (content: Array) => { @@ -536,7 +612,7 @@ export function PolicyContentManager({ }} > - +
{/* Left side: Tabs */}
@@ -843,16 +919,45 @@ export function PolicyContentManager({ )}
- +
+ + {/* Mobile/tablet and medium desktop: AI assistant above the editor */} + {aiAssistantEnabled && showAiAssistant && !isVersionReadOnly && activeTab === 'EDITOR' && !isWideDesktop && ( +
+ setShowAiAssistant(false)} + /> +
+ )}
-
+
+ {suggestions.isActive && ( + { + suggestions.dismissAll(); + if (activeProposal) setDismissedProposalKey(activeProposal.key); + }} + /> + )} @@ -884,52 +992,24 @@ export function PolicyContentManager({
- {aiAssistantEnabled && showAiAssistant && !isVersionReadOnly && activeTab === 'EDITOR' && ( -
+ {/* Wide desktop (1536px+): AI assistant side panel */} + {aiAssistantEnabled && showAiAssistant && !isVersionReadOnly && activeTab === 'EDITOR' && isWideDesktop && ( +
setShowAiAssistant(false)} - hasActiveProposal={!!activeProposal && !hasPendingProposal} />
)}
+ - {proposedPolicyMarkdown && diffPatch && activeProposal && !hasPendingProposal && ( -
- - - - } - > - -
- )} - {/* Create Version Dialog */} ): string { - function extractText(node: JSONContent): string { - if (node.type === 'text' && typeof node.text === 'string') { - return node.text; - } - - if (Array.isArray(node.content)) { - const texts = node.content.map(extractText).filter(Boolean); - - switch (node.type) { - case 'heading': { - const level = (node.attrs as Record)?.level || 1; - return '\n' + '#'.repeat(Number(level)) + ' ' + texts.join('') + '\n'; - } - case 'paragraph': - return texts.join('') + '\n'; - case 'bulletList': - case 'orderedList': - return '\n' + texts.join(''); - case 'listItem': - return '- ' + texts.join('') + '\n'; - case 'blockquote': - return '\n> ' + texts.join('\n> ') + '\n'; - default: - return texts.join(''); - } - } - - return ''; - } - - return content.map(extractText).join('\n').trim(); -} - function PolicyEditorWrapper({ policyId, versionId, @@ -1087,6 +1115,9 @@ function PolicyEditorWrapper({ onContentChange, onVersionContentChange, saveVersionContent, + onEditorReady, + additionalExtensions, + suggestionsActive = false, }: { policyId: string; versionId: string; @@ -1099,6 +1130,9 @@ function PolicyEditorWrapper({ onContentChange?: (content: Array) => void; onVersionContentChange?: (versionId: string, content: JSONContent[]) => void; saveVersionContent: (versionId: string, content: JSONContent[]) => Promise; + onEditorReady?: (editor: TipTapEditor) => void; + additionalExtensions?: import('@tiptap/core').Extension[]; + suggestionsActive?: boolean; }) { const { hasPermission } = usePermissions(); const canUpdatePolicy = hasPermission('policy', 'update'); @@ -1176,18 +1210,23 @@ function PolicyEditorWrapper({ return (
- {statusInfo && ( + {statusInfo && !suggestionsActive && (
{statusInfo.message}
)} - +
+ +
); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/markdown-utils.test.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/markdown-utils.test.ts new file mode 100644 index 0000000000..d2871f6185 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/markdown-utils.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; +import { markdownToTipTapJSON } from '../markdown-utils'; + +describe('markdownToTipTapJSON', () => { + describe('headings', () => { + it('converts ## heading to heading node with level 2', () => { + const result = markdownToTipTapJSON('## Title'); + + expect(result).toEqual([ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Title' }], + }, + ]); + }); + + it('converts # heading to level 1', () => { + const result = markdownToTipTapJSON('# Top Level'); + + expect(result).toEqual([ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Top Level' }], + }, + ]); + }); + + it('converts ### heading to level 3', () => { + const result = markdownToTipTapJSON('### Sub Section'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'heading', + attrs: { level: 3 }, + }); + }); + + it('supports heading levels 1 through 6', () => { + for (let level = 1; level <= 6; level++) { + const hashes = '#'.repeat(level); + const result = markdownToTipTapJSON(`${hashes} Heading`); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'heading', + attrs: { level }, + }); + } + }); + }); + + describe('paragraphs', () => { + it('converts plain text to paragraph node', () => { + const result = markdownToTipTapJSON('Hello world'); + + expect(result).toEqual([ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ]); + }); + + it('converts multiple lines to separate paragraphs', () => { + const result = markdownToTipTapJSON('First line\nSecond line'); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'First line' }], + }); + expect(result[1]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'Second line' }], + }); + }); + + it('trims leading and trailing whitespace from lines', () => { + const result = markdownToTipTapJSON(' Hello '); + + expect(result).toHaveLength(1); + expect(result[0]!.content![0]).toMatchObject({ + type: 'text', + text: 'Hello', + }); + }); + }); + + describe('bullet lists', () => { + it('converts - item to bulletList with listItem child', () => { + const result = markdownToTipTapJSON('- Item one'); + + expect(result).toEqual([ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Item one' }], + }, + ], + }, + ], + }, + ]); + }); + + it('converts * item to bulletList', () => { + const result = markdownToTipTapJSON('* Starred item'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(1); + expect(result[0]!.content![0]!.content![0]!.content![0]).toMatchObject({ + type: 'text', + text: 'Starred item', + }); + }); + + it('groups consecutive bullet items into ONE bulletList', () => { + const result = markdownToTipTapJSON('- First\n- Second\n- Third'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(3); + expect(result[0]!.content![0]!.content![0]!.content![0]).toMatchObject({ + text: 'First', + }); + expect(result[0]!.content![1]!.content![0]!.content![0]).toMatchObject({ + text: 'Second', + }); + expect(result[0]!.content![2]!.content![0]!.content![0]).toMatchObject({ + text: 'Third', + }); + }); + }); + + describe('ordered lists', () => { + it('converts 1. item to orderedList with listItem child', () => { + const result = markdownToTipTapJSON('1. First item'); + + expect(result).toEqual([ + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First item' }], + }, + ], + }, + ], + }, + ]); + }); + + it('groups consecutive ordered items into ONE orderedList', () => { + const result = markdownToTipTapJSON('1. Alpha\n2. Beta\n3. Gamma'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('orderedList'); + expect(result[0]!.content).toHaveLength(3); + }); + + it('handles arbitrary numbering', () => { + const result = markdownToTipTapJSON('5. Item five\n10. Item ten'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('orderedList'); + expect(result[0]!.content).toHaveLength(2); + }); + }); + + describe('blank lines flush list groups', () => { + it('blank line between bullet items creates two separate bulletLists', () => { + const result = markdownToTipTapJSON('- First\n\n- Second'); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('bulletList'); + expect(result[1]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(1); + expect(result[1]!.content).toHaveLength(1); + }); + + it('blank line between ordered items creates two separate orderedLists', () => { + const result = markdownToTipTapJSON('1. First\n\n2. Second'); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('orderedList'); + expect(result[1]!.type).toBe('orderedList'); + }); + }); + + describe('list type transitions', () => { + it('switching from bullet to ordered creates separate lists', () => { + const result = markdownToTipTapJSON('- Bullet\n1. Ordered'); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('bulletList'); + expect(result[1]!.type).toBe('orderedList'); + }); + + it('switching from ordered to bullet creates separate lists', () => { + const result = markdownToTipTapJSON('1. Ordered\n- Bullet'); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('orderedList'); + expect(result[1]!.type).toBe('bulletList'); + }); + }); + + describe('mixed content', () => { + it('heading + paragraph + bullets + paragraph', () => { + const md = [ + '## Introduction', + 'Some introductory text.', + '- Point one', + '- Point two', + 'Conclusion paragraph.', + ].join('\n'); + + const result = markdownToTipTapJSON(md); + + expect(result).toHaveLength(4); + expect(result[0]).toMatchObject({ + type: 'heading', + attrs: { level: 2 }, + }); + expect(result[1]).toMatchObject({ type: 'paragraph' }); + expect(result[2]).toMatchObject({ type: 'bulletList' }); + expect(result[2]!.content).toHaveLength(2); + expect(result[3]).toMatchObject({ type: 'paragraph' }); + }); + + it('heading flushes an active list', () => { + const md = [ + '- Item A', + '- Item B', + '## Next Section', + ].join('\n'); + + const result = markdownToTipTapJSON(md); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(2); + expect(result[1]!.type).toBe('heading'); + }); + + it('paragraph between lists flushes and creates separate lists', () => { + const md = [ + '- First list item', + 'A paragraph in between.', + '- Second list item', + ].join('\n'); + + const result = markdownToTipTapJSON(md); + + expect(result).toHaveLength(3); + expect(result[0]!.type).toBe('bulletList'); + expect(result[1]!.type).toBe('paragraph'); + expect(result[2]!.type).toBe('bulletList'); + }); + }); + + describe('edge cases', () => { + it('empty input returns empty paragraph', () => { + const result = markdownToTipTapJSON(''); + + expect(result).toEqual([ + { + type: 'paragraph', + content: [{ type: 'text', text: '' }], + }, + ]); + }); + + it('whitespace-only input returns empty paragraph', () => { + const result = markdownToTipTapJSON(' \n \n '); + + expect(result).toEqual([ + { + type: 'paragraph', + content: [{ type: 'text', text: '' }], + }, + ]); + }); + + it('trailing blank lines are ignored', () => { + const result = markdownToTipTapJSON('Hello\n\n\n'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }); + }); + + it('leading blank lines are ignored', () => { + const result = markdownToTipTapJSON('\n\nHello'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }); + }); + + it('unclosed list at end of input is flushed', () => { + const result = markdownToTipTapJSON('- Alpha\n- Beta'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(2); + }); + + it('single bullet item produces a bulletList with one item', () => { + const result = markdownToTipTapJSON('- Solo'); + + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('bulletList'); + expect(result[0]!.content).toHaveLength(1); + }); + + it('heading with no preceding blank line still produces heading', () => { + const result = markdownToTipTapJSON('Some text\n## Heading'); + + expect(result).toHaveLength(2); + expect(result[0]!.type).toBe('paragraph'); + expect(result[1]!.type).toBe('heading'); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx index 2ec8622f0e..b53b6b74f1 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx @@ -1,25 +1,25 @@ 'use client'; import { - AIChatBody, - Badge, - Button, - Card, - HStack, - Stack, - Text, - Textarea, -} from '@trycompai/design-system'; + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; import { - ArrowDown, - CheckmarkFilled, - CircleFilled, - Close, - Error, - Time, -} from '@trycompai/design-system/icons'; + Message, + MessageContent, + MessageResponse, +} from '@/components/ai-elements/message'; +import { + PromptInput, + PromptInputTextarea, + PromptInputFooter, + PromptInputSubmit, +} from '@/components/ai-elements/prompt-input'; +import { Button } from '@trycompai/design-system'; +import { Close, MagicWand } from '@trycompai/design-system/icons'; import type { ChatStatus } from 'ai'; -import { useState } from 'react'; import type { PolicyChatUIMessage } from '../../types'; interface PolicyAiAssistantProps { @@ -27,8 +27,8 @@ interface PolicyAiAssistantProps { status: ChatStatus; errorMessage?: string | null; sendMessage: (payload: { text: string }) => void; + stop?: () => void; close?: () => void; - hasActiveProposal?: boolean; } export function PolicyAiAssistant({ @@ -36,244 +36,290 @@ export function PolicyAiAssistant({ status, errorMessage, sendMessage, + stop, close, - hasActiveProposal, }: PolicyAiAssistantProps) { - const [input, setInput] = useState(''); + const isBusy = status === 'streaming' || status === 'submitted'; - const isLoading = status === 'streaming' || status === 'submitted'; + return ( +
+ {close && ( +
+ AI Assistant +
+
+
+ )} - const hasActiveTool = messages.some( - (m) => - m.role === 'assistant' && - m.parts.some( - (p) => - p.type === 'tool-proposePolicy' && - (p.state === 'input-streaming' || - p.state === 'output-available' || - p.state === 'output-error'), - ), - ); + + + {messages.length === 0 ? ( + +
+ +
+
+

Policy AI Assistant

+

I can help you edit, adapt, or check this policy for compliance.

+
+
+

"Add a section covering third-party vendor access controls."

+

"Update the incident response steps to align with SOC 2."

+

"Rewrite the data retention clause for GDPR compliance."

+
+
+ ) : ( + <> + {messages.map((message, messageIndex) => { + // A tool in an older message that never completed was interrupted. + // A tool in the latest message is only interrupted if we're no longer busy. + const isLastMessage = messageIndex === messages.length - 1; + const isMessageStopped = isLastMessage ? !isBusy : true; - const handleSubmit = () => { - if (!input.trim()) return; - sendMessage({ text: input }); - setInput(''); - }; + return ( + + + {message.parts.map((part, index) => { + if (part.type === 'text') { + if (message.role === 'user') { + return ( +
+
+ {part.text} +
+
+ ); + } + return ( + + {part.text} + + ); + } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; + if (part.type === 'tool-proposePolicy') { + return ( + + ); + } - return ( - - - - ) - } - > - - - - {messages.length === 0 ? ( - - - I can help you edit, adapt, or check this policy for compliance. Try asking me - things like: - - - - "Adapt this for a fully remote, distributed team." - - - "Can I shorten the data retention timeframe and still meet SOC 2 standards?" - - - "Modify the access control section to include contractors." - - - - ) : ( - messages.map((message) => ( - - )) - )} - {isLoading && !hasActiveTool && ( - - Thinking... - - )} - - + if ( + part.type === 'tool-listVendors' || + part.type === 'tool-getVendor' || + part.type === 'tool-listPolicies' || + part.type === 'tool-getPolicy' || + part.type === 'tool-listEvidence' + ) { + return ( + + ); + } + + return null; + })} +
+
+ ); + })} + + + )} +
+ +
+
{errorMessage && ( - - - {errorMessage} - - +
+ {errorMessage} +
)} - -