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/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 || 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..60977367eb --- /dev/null +++ b/apps/api/src/integration-platform/controllers/ramp-role-mapping.controller.ts @@ -0,0 +1,196 @@ +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 { RampRoleMappingService } from '../services/ramp-role-mapping.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; +import { RampApiService } from '../services/ramp-api.service'; +import { 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 roleMappingService: RampRoleMappingService, + private readonly syncLoggerService: IntegrationSyncLoggerService, + private readonly rampApiService: RampApiService, + ) {} + + @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 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 }>; + + // Use cached roles unless refresh is requested + const cachedRoles = shouldRefresh + ? null + : await this.roleMappingService.getCachedDiscoveredRoles(connectionId); + + if (cachedRoles) { + discoveredRoles = cachedRoles; + } else { + const logId = await this.syncLoggerService.startLog({ + connectionId, + organizationId, + provider: 'ramp', + eventType: 'role_discovery', + triggeredBy: 'manual', + }); + + try { + const accessToken = await this.rampApiService.getAccessToken(connectionId, organizationId); + const users = await this.rampApiService.fetchUsers(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 (preserve existing mapping if any) + const existingMapping = await this.roleMappingService.getSavedMapping(connectionId); + if (existingMapping) { + await this.roleMappingService.saveMapping( + connectionId, + existingMapping, + discoveredRoles, + ); + } else { + await this.roleMappingService.saveDiscoveredRoles(connectionId, 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); + 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); + } + + 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); + + 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') + @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 }; + } +} diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index d050b99ba2..07ecb378b7 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,11 @@ import { type RampUser, type RampUserStatus, type RampUsersResponse, + type RoleMappingEntry, } 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; @@ -103,6 +111,9 @@ export class SyncController { private readonly connectionRepository: ConnectionRepository, private readonly credentialVaultService: CredentialVaultService, private readonly oauthCredentialsService: OAuthCredentialsService, + private readonly rampRoleMappingService: RampRoleMappingService, + private readonly syncLoggerService: IntegrationSyncLoggerService, + private readonly rampApiService: RampApiService, ) {} /** @@ -977,6 +988,7 @@ export class SyncController { async syncRampEmployees( @OrganizationId() organizationId: string, @Query('connectionId') connectionId: string, + @AuthContext() authContext: AuthContextType, ) { if (!connectionId) { throw new HttpException( @@ -1005,169 +1017,50 @@ export class SyncController { ); } - const manifest = getManifest('ramp'); - if (!manifest) { - throw new HttpException( - 'Ramp manifest not found', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + const triggeredBy = + authContext.authType === 'service' + ? 'scheduled' + : authContext.authType === 'api-key' + ? 'api' + : 'manual'; - let credentials = - await this.credentialVaultService.getDecryptedCredentials(connectionId); + const logId = await this.syncLoggerService.startLog({ + connectionId, + organizationId, + provider: 'ramp', + eventType: 'employee_sync', + triggeredBy, + userId: authContext.userId ?? undefined, + }); - if (!credentials?.access_token) { - throw new HttpException( - 'No valid credentials found. Please reconnect the integration.', - HttpStatus.UNAUTHORIZED, + try { + return await this.syncRampEmployeesInner( + organizationId, + connectionId, + authContext, + connection, + logId, ); - } - - 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 = credentials?.access_token; - if (!accessToken) { - throw new HttpException( - 'No valid credentials found. Please reconnect the integration.', - HttpStatus.UNAUTHORIZED, + } catch (error) { + await this.syncLoggerService.failLog( + logId, + error instanceof Error ? error.message : String(error), ); + throw error; } + } - 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://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; - }; + private async syncRampEmployeesInner( + organizationId: string, + connectionId: string, + authContext: AuthContextType, + connection: { variables: unknown }, + logId: string, + ) { + const accessToken = await this.rampApiService.getAccessToken(connectionId, organizationId); - 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) @@ -1208,8 +1101,120 @@ 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 + await this.syncLoggerService.completeLog(logId, { + requiresRoleMapping: true, + message: 'Role mapping is not configured', + }); + 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', + ); + const emptyResult = { + totalFound: 0, + imported: 0, + skipped: 0, + deactivated: 0, + reactivated: 0, + errors: 0, + details: [], + }; + await this.syncLoggerService.completeLog(logId, emptyResult); + return { success: true, ...emptyResult }; + } + + 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, + updated: 0, skipped: 0, deactivated: 0, reactivated: 0, @@ -1218,6 +1223,7 @@ export class SyncController { email: string; status: | 'imported' + | 'updated' | 'skipped' | 'deactivated' | 'reactivated' @@ -1258,25 +1264,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 +1306,26 @@ 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, + }); + 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({ @@ -1314,11 +1356,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, @@ -1411,21 +1456,46 @@ 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`, ); - return { + // Update lastSyncAt on the connection + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { lastSyncAt: new Date() }, + }); + + const syncResult = { success: true, totalFound: activeUsers.length, totalInactive: inactiveUsers.length, totalSuspended: suspendedUsers.length, ...results, }; + + await this.syncLoggerService.completeLog(logId, { + imported: results.imported, + updated: results.updated, + 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 ab538f4e44..1632dbfdd6 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,9 @@ 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'; +import { IntegrationSyncLoggerService } from './services/integration-sync-logger.service'; +import { RampApiService } from './services/ramp-api.service'; @Module({ imports: [AuthModule], @@ -40,6 +44,7 @@ import { DynamicCheckRepository } from './repositories/dynamic-check.repository' TaskIntegrationsController, WebhookController, SyncController, + RampRoleMappingController, ], providers: [ // Services @@ -50,6 +55,9 @@ import { DynamicCheckRepository } from './repositories/dynamic-check.repository' OAuthTokenRevocationService, ConnectionAuthTeardownService, DynamicManifestLoaderService, + RampRoleMappingService, + IntegrationSyncLoggerService, + RampApiService, // 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/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/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..7e82ed05f5 --- /dev/null +++ b/apps/api/src/integration-platform/services/ramp-role-mapping.service.ts @@ -0,0 +1,235 @@ +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. + */ + 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; + } + + 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, + }, + }); + } + + /** + * 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 + */ + 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/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]/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..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 @@ -11,14 +11,41 @@ type SyncProvider = 'google-workspace' | 'rippling' | 'jumpcloud' | 'ramp'; interface SyncResult { success: boolean; + requiresRoleMapping?: boolean; totalFound: number; imported: number; + updated: number; reactivated: number; deactivated: number; skipped: number; 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 +63,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 +98,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,12 +161,45 @@ 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; + 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' : ''}`); } @@ -140,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) { @@ -166,6 +234,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 +302,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..fad2b4f12c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/role-mapping/components/RampRoleMappingContent.tsx @@ -0,0 +1,195 @@ +'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 (error) { + toast.error('Failed to load Ramp roles'); + throw error; + } + }; + + useEffect(() => { + const load = async () => { + setIsLoading(true); + try { + await fetchRoles(); + } catch { + // error toast already shown by fetchRoles + } finally { + setIsLoading(false); + } + }; + load(); + }, [organizationId, connectionId]); + + const handleRefresh = async () => { + setIsRefreshing(true); + 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) => { + 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/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..305c07b5f9 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,13 @@ 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 { InlineEditBubble } from './ai/inline-edit-bubble'; +import { markdownToTipTapJSON } from './ai/markdown-utils'; + +import { SuggestionsTopBar } from './ai/suggestions-top-bar'; type PolicyVersionWithPublisher = PolicyVersion & { publishedBy: (Member & { user: User }) | null; @@ -87,29 +91,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 +192,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 +205,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 +469,7 @@ export function PolicyContentManager({ messages, status, sendMessage: baseSendMessage, + stop: stopChat, } = useChat({ transport: new DefaultChatTransport({ api: `/api/policies/${policyId}/chat`, @@ -439,30 +482,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]); + // ── 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 activeProposal = - latestProposal && latestProposal.key !== dismissedProposalKey ? latestProposal : null; - - 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,42 +527,40 @@ export function PolicyContentManager({ } }; - const currentPolicyMarkdown = useMemo( - () => convertContentToMarkdown(currentContent), - [currentContent], - ); - - const diffPatch = useMemo(() => { - if (!proposedPolicyMarkdown) return null; - return createGitPatch('Proposed Changes', currentPolicyMarkdown, proposedPolicyMarkdown); - }, [currentPolicyMarkdown, proposedPolicyMarkdown]); - - async function applyProposedChanges() { - if (!activeProposal || !viewingVersion) return; + const suggestions = useSuggestions({ + editor: editorInstance, + proposedMarkdown: proposedPolicyMarkdown, + }); - // 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; - - 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); + // 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, + }; + // Filter out per-hunk feedback messages (and their AI responses) from chat display // Track local changes made in editor (after save) const handleContentSaved = (content: Array) => { setCurrentContent(content); @@ -536,7 +582,7 @@ export function PolicyContentManager({ }} > - +
{/* Left side: Tabs */}
@@ -843,16 +889,52 @@ 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); + }} + /> + )} + {editorInstance && !isVersionReadOnly && canUpdatePolicy && ( + + )} @@ -884,52 +969,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 +1092,9 @@ function PolicyEditorWrapper({ onContentChange, onVersionContentChange, saveVersionContent, + onEditorReady, + additionalExtensions, + suggestionsActive = false, }: { policyId: string; versionId: string; @@ -1099,6 +1107,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 +1187,23 @@ function PolicyEditorWrapper({ return (
- {statusInfo && ( + {statusInfo && !suggestionsActive && (
{statusInfo.message}
)} - +
+ +
); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/inline-edit-bubble.test.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/inline-edit-bubble.test.ts new file mode 100644 index 0000000000..c92812b7e0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/__tests__/inline-edit-bubble.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Tests for the sliceToMarkdown utility used by InlineEditBubble. + * Since sliceToMarkdown is a private function, we test its behavior + * indirectly by verifying the markdown conversion logic. + * + * The actual component tests would require a full TipTap editor setup, + * so we focus on the pure logic: converting ProseMirror node structures + * to markdown strings that the AI can process. + */ + +// Re-implement sliceToMarkdown logic for isolated testing +function sliceToMarkdown( + nodes: Array<{ type: string; text?: string; level?: number; inList?: boolean }>, +): string { + const lines: string[] = []; + + for (const node of nodes) { + if (node.type === 'heading') { + const level = node.level ?? 2; + lines.push('#'.repeat(level) + ' ' + (node.text ?? '')); + } else if (node.type === 'paragraph' && node.inList) { + lines.push('- ' + (node.text ?? '')); + } else if (node.type === 'paragraph') { + lines.push(node.text ?? ''); + } + } + + return lines.join('\n'); +} + +describe('sliceToMarkdown logic', () => { + it('converts a heading to markdown', () => { + const result = sliceToMarkdown([ + { type: 'heading', text: 'Purpose', level: 2 }, + ]); + expect(result).toBe('## Purpose'); + }); + + it('converts a heading with different levels', () => { + expect(sliceToMarkdown([{ type: 'heading', text: 'Sub', level: 3 }])).toBe('### Sub'); + expect(sliceToMarkdown([{ type: 'heading', text: 'Title', level: 1 }])).toBe('# Title'); + }); + + it('converts a plain paragraph', () => { + const result = sliceToMarkdown([ + { type: 'paragraph', text: 'Some policy content.' }, + ]); + expect(result).toBe('Some policy content.'); + }); + + it('converts a list item paragraph with bullet prefix', () => { + const result = sliceToMarkdown([ + { type: 'paragraph', text: 'First bullet', inList: true }, + ]); + expect(result).toBe('- First bullet'); + }); + + it('converts mixed content preserving structure', () => { + const result = sliceToMarkdown([ + { type: 'heading', text: 'Scope', level: 2 }, + { type: 'paragraph', text: 'Overview text.' }, + { type: 'paragraph', text: 'All employees', inList: true }, + { type: 'paragraph', text: 'All contractors', inList: true }, + { type: 'paragraph', text: 'Closing paragraph.' }, + ]); + expect(result).toBe( + '## Scope\nOverview text.\n- All employees\n- All contractors\nClosing paragraph.', + ); + }); + + it('handles empty text', () => { + const result = sliceToMarkdown([ + { type: 'paragraph', text: '' }, + ]); + expect(result).toBe(''); + }); + + it('handles multiple consecutive bullets', () => { + const result = sliceToMarkdown([ + { type: 'paragraph', text: 'Item A', inList: true }, + { type: 'paragraph', text: 'Item B', inList: true }, + { type: 'paragraph', text: 'Item C', inList: true }, + ]); + expect(result).toBe('- Item A\n- Item B\n- Item C'); + }); +}); + +describe('inline edit flow', () => { + it('single bullet selection should include bullet prefix in markdown', () => { + const markdown = sliceToMarkdown([ + { type: 'paragraph', text: 'Lock server racks', inList: true }, + ]); + expect(markdown).toBe('- Lock server racks'); + expect(markdown.startsWith('- ')).toBe(true); + }); + + it('heading + bullets selection preserves full structure', () => { + const markdown = sliceToMarkdown([ + { type: 'heading', text: 'Equipment Protection', level: 2 }, + { type: 'paragraph', text: 'Lock server racks', inList: true }, + { type: 'paragraph', text: 'Maintain inventory', inList: true }, + ]); + expect(markdown).toBe( + '## Equipment Protection\n- Lock server racks\n- Maintain inventory', + ); + }); + + it('paragraph-only selection has no markdown formatting', () => { + const markdown = sliceToMarkdown([ + { type: 'paragraph', text: 'Retain records for 12 months.' }, + ]); + expect(markdown).toBe('Retain records for 12 months.'); + expect(markdown).not.toContain('#'); + expect(markdown).not.toContain('- '); + }); +}); 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/inline-edit-bubble.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/inline-edit-bubble.tsx new file mode 100644 index 0000000000..d042dc75c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/inline-edit-bubble.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { Editor } from '@tiptap/react'; +import { BubbleMenu } from '@tiptap/react/menus'; +import { MagicWand } from '@trycompai/design-system/icons'; +import { markdownToTipTapJSON } from './markdown-utils'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; + +interface InlineEditBubbleProps { + editor: Editor; + policyId: string; + disabled?: boolean; +} + +export function InlineEditBubble({ editor, policyId, disabled }: InlineEditBubbleProps) { + const [isEditing, setIsEditing] = useState(false); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [isEditing]); + + const handleSubmit = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const { from, to } = editor.state.selection; + const selectedText = editor.state.doc.textBetween(from, to, ' '); + if (!selectedText.trim()) return; + + // Resolve to top-level (depth 1) block boundaries. + // This ensures we always replace complete top-level nodes + // (paragraphs, headings, entire
    elements), never partial + // inline content or listItem internals. + const $from = editor.state.doc.resolve(from); + const $to = editor.state.doc.resolve(to); + const replaceFrom = $from.before(1); + const replaceTo = $to.after(1); + + // Extract selected content as markdown to preserve structure + const selectedMarkdown = sliceToMarkdown(editor.state.doc, replaceFrom, replaceTo); + + setIsLoading(true); + + try { + const res = await fetch(`/api/policies/${policyId}/edit-section`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + sectionText: selectedMarkdown, + feedback: input, + }), + }); + + if (!res.ok) throw new Error('Failed'); + const { updatedText } = (await res.json()) as { updatedText: string }; + + // Convert AI response back to TipTap nodes and replace the block range + const jsonNodes = markdownToTipTapJSON(updatedText); + const pmNodes = jsonNodes.map((json) => + editor.state.schema.nodeFromJSON(json), + ); + + const { tr } = editor.state; + tr.replaceWith(replaceFrom, replaceTo, pmNodes); + editor.view.dispatch(tr); + + setInput(''); + setIsEditing(false); + } catch (err) { + console.error('Inline edit failed:', err); + } finally { + setIsLoading(false); + } + }, [editor, input, isLoading, policyId]); + + const handleClose = useCallback(() => { + setIsEditing(false); + setInput(''); + }, []); + + if (disabled) return null; + + const shouldShow = ({ editor: e }: { editor: Editor }) => { + const { from, to } = e.state.selection; + return from !== to && e.isEditable; + }; + + return ( + { + setIsEditing(false); + setInput(''); + }, + }} + > +
    + {isEditing ? ( +
    + + setInput(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + } + }} + placeholder="How should this change?" + disabled={isLoading} + className="w-48 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> + {isLoading ? ( + + + + ) : ( + + )} +
    + ) : ( + + )} +
    +
    + ); +} + +/** + * Convert a range of ProseMirror nodes to markdown so the AI + * receives the content with structure (headings, bullets) intact. + */ +function sliceToMarkdown(doc: ProseMirrorNode, from: number, to: number): string { + const lines: string[] = []; + + doc.nodesBetween(from, to, (node, pos) => { + if (node.isBlock && !node.isTextblock && node.type.name !== 'doc') { + return true; + } + + if (node.type.name === 'heading') { + const level = (node.attrs as { level?: number }).level ?? 2; + lines.push('#'.repeat(level) + ' ' + node.textContent); + return false; + } + + if (node.type.name === 'paragraph') { + const $pos = doc.resolve(pos); + let insideListItem = false; + for (let d = $pos.depth; d >= 0; d--) { + if ($pos.node(d).type.name === 'listItem') { + insideListItem = true; + break; + } + } + if (insideListItem) { + lines.push('- ' + node.textContent); + } else { + lines.push(node.textContent); + } + return false; + } + + return true; + }); + + return lines.join('\n'); +} 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} +
    )} - -