diff --git a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts index 93786aae7f..a987232f48 100644 --- a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts @@ -65,6 +65,11 @@ export class DynamicIntegrationsController { ? SyncDefinitionSchema.parse(rawSyncDef) : undefined; + const rawDeviceSyncDef = body.deviceSyncDefinition; + const validatedDeviceSyncDef = rawDeviceSyncDef + ? SyncDefinitionSchema.parse(rawDeviceSyncDef) + : undefined; + // Upsert the integration const integration = await this.dynamicIntegrationRepo.upsertBySlug({ slug: def.slug, @@ -83,6 +88,11 @@ export class DynamicIntegrationsController { JSON.stringify(validatedSyncDef), ) as Prisma.InputJsonValue) : null, + deviceSyncDefinition: validatedDeviceSyncDef + ? (JSON.parse( + JSON.stringify(validatedDeviceSyncDef), + ) as Prisma.InputJsonValue) + : null, services: (def.services as unknown as Prisma.InputJsonValue) ?? undefined, }); @@ -167,6 +177,12 @@ export class DynamicIntegrationsController { const validatedSyncDefCreate = rawSyncDefCreate ? SyncDefinitionSchema.parse(rawSyncDefCreate) : undefined; + + const rawDeviceSyncDefCreate = body.deviceSyncDefinition; + const validatedDeviceSyncDefCreate = rawDeviceSyncDefCreate + ? SyncDefinitionSchema.parse(rawDeviceSyncDefCreate) + : undefined; + const integration = await this.dynamicIntegrationRepo.create({ slug: def.slug, name: def.name, @@ -184,6 +200,11 @@ export class DynamicIntegrationsController { JSON.stringify(validatedSyncDefCreate), ) as Prisma.InputJsonValue) : undefined, + deviceSyncDefinition: validatedDeviceSyncDefCreate + ? (JSON.parse( + JSON.stringify(validatedDeviceSyncDefCreate), + ) as Prisma.InputJsonValue) + : undefined, }); for (const [index, check] of def.checks.entries()) { diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index c893a27068..8b74560927 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -27,11 +27,13 @@ import { matchesSyncFilterTerms, parseSyncFilterTerms, interpretDeclarativeSync, + SyncDeviceSchema, type OAuthConfig, type SyncDefinition, } from '@trycompai/integration-platform'; import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; import { CheckRunRepository } from '../repositories/check-run.repository'; import { createCheckContext } from '@trycompai/integration-platform'; @@ -73,6 +75,7 @@ export class SyncController { private readonly oauthCredentialsService: OAuthCredentialsService, private readonly syncLoggerService: IntegrationSyncLoggerService, private readonly genericSyncService: GenericEmployeeSyncService, + private readonly genericDeviceSyncService: GenericDeviceSyncService, private readonly dynamicIntegrationRepo: DynamicIntegrationRepository, private readonly checkRunRepo: CheckRunRepository, ) {} @@ -1575,6 +1578,74 @@ export class SyncController { }; } + /** + * Get the current device sync provider for an organization + */ + @Get('device-sync-provider') + @ApiOperation({ summary: 'Get the currently configured device sync provider' }) + @RequirePermission('integration', 'read') + async getDeviceSyncProvider(@OrganizationId() organizationId: string) { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { deviceSyncProvider: true }, + }); + + if (!org) { + throw new HttpException('Organization not found', HttpStatus.NOT_FOUND); + } + + return { provider: org.deviceSyncProvider }; + } + + /** + * Set the device sync provider for an organization + */ + @Post('device-sync-provider') + @ApiOperation({ summary: 'Set the device sync provider' }) + @RequirePermission('integration', 'update') + async setDeviceSyncProvider( + @OrganizationId() organizationId: string, + @Body() body: { provider: string | null }, + ) { + const { provider } = body; + + if (provider) { + const allManifests = registry.getActiveManifests(); + const validProviders = allManifests + .filter((m) => m.capabilities?.includes('device_sync')) + .map((m) => m.id); + if (!validProviders.includes(provider)) { + throw new HttpException( + `Invalid device sync provider. Must be one of: ${validProviders.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + const connection = await this.connectionRepository.findBySlugAndOrg( + provider, + organizationId, + ); + + if (!connection || connection.status !== 'active') { + throw new HttpException( + `Provider ${provider} is not connected`, + HttpStatus.BAD_REQUEST, + ); + } + } + + await db.organization.update({ + where: { id: organizationId }, + data: { deviceSyncProvider: provider }, + }); + + this.logger.log( + `Set device sync provider to ${provider || 'none'} for org ${organizationId}`, + ); + + return { success: true, provider }; + } + // ============================================================================ // Dynamic sync endpoints (for dynamic integrations with syncDefinition) // ============================================================================ @@ -1584,12 +1655,16 @@ export class SyncController { * Used by the frontend to render the provider selector dynamically. */ @Get('available-providers') - @ApiOperation({ summary: 'List employee sync providers available to the org' }) + @ApiOperation({ summary: 'List sync providers available to the org' }) @RequirePermission('integration', 'read') - async getAvailableSyncProviders(@OrganizationId() organizationId: string) { + async getAvailableSyncProviders( + @OrganizationId() organizationId: string, + @Query('syncType') syncType?: 'employee' | 'device', + ) { + const capability = syncType === 'device' ? 'device_sync' : 'sync'; const allManifests = registry.getActiveManifests(); const syncProviders = allManifests.filter((m) => - m.capabilities?.includes('sync'), + m.capabilities?.includes(capability), ); const results = await Promise.all( @@ -1850,4 +1925,240 @@ export class SyncController { ); } } + + /** + * Generic device sync endpoint for dynamic integrations. + * Runs the deviceSyncDefinition (DSL/code steps) and processes the resulting devices. + */ + @Post('dynamic/:providerSlug/devices') + @ApiOperation({ summary: 'Sync devices for a dynamic provider' }) + @RequirePermission('integration', 'update') + async syncDynamicProviderDevices( + @OrganizationId() organizationId: string, + @Param('providerSlug') providerSlug: string, + @Query('connectionId') connectionId: string, + ) { + if (!connectionId) { + throw new HttpException( + 'connectionId is required', + HttpStatus.BAD_REQUEST, + ); + } + + this.logger.log( + `[DeviceSync] Starting sync for provider="${providerSlug}" connection="${connectionId}" org="${organizationId}"`, + ); + + // 1. Validate connection + const connection = await this.connectionRepository.findById(connectionId); + if (!connection || connection.organizationId !== organizationId) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + + // 2. Get manifest from registry — must have 'device_sync' capability + const manifest = getManifest(providerSlug); + if (!manifest) { + throw new HttpException( + `Integration "${providerSlug}" not found`, + HttpStatus.NOT_FOUND, + ); + } + if (!manifest.capabilities?.includes('device_sync')) { + throw new HttpException( + `Integration "${providerSlug}" does not support device sync`, + HttpStatus.BAD_REQUEST, + ); + } + + // 3. Get dynamic integration — must have deviceSyncDefinition + const dynamicIntegration = + await this.dynamicIntegrationRepo.findBySlug(providerSlug); + if (!dynamicIntegration?.deviceSyncDefinition) { + throw new HttpException( + `Integration "${providerSlug}" has no device sync definition`, + HttpStatus.BAD_REQUEST, + ); + } + + // 4. Get & refresh credentials + let credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + if (!credentials) { + throw new HttpException( + 'No valid credentials found. Please reconnect the integration.', + HttpStatus.UNAUTHORIZED, + ); + } + + // Try to refresh OAuth token if applicable + if (manifest.auth.type === 'oauth2' && credentials.refresh_token) { + const oauthConfig = manifest.auth.config; + try { + const oauthCredentials = + await this.oauthCredentialsService.getCredentials( + providerSlug, + 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, + ); + } + } + } catch (refreshError) { + this.logger.warn( + `Token refresh failed for ${providerSlug}, trying with existing token: ${refreshError}`, + ); + } + } + + const accessToken = credentials?.access_token; + this.logger.log( + `[DeviceSync] Credentials ready for "${providerSlug}" (auth=${manifest.auth.type}, hasToken=${!!accessToken})`, + ); + + // 5. Create a sync run record + const syncRun = await this.checkRunRepo.create({ + connectionId, + checkId: `device-sync:${providerSlug}`, + checkName: `Device Sync: ${manifest.name}`, + }); + + // 6. Create CheckContext with logging that captures to the run + const { ctx, getResults } = createCheckContext({ + manifest, + accessToken: typeof accessToken === 'string' ? accessToken : undefined, + credentials: (credentials ?? {}) as Record, + variables: ((connection.variables as Record) ?? + {}) as Record, + connectionId, + organizationId, + metadata: (connection.metadata as Record) ?? {}, + logger: { + info: (msg, data) => this.logger.log(msg, data), + warn: (msg, data) => this.logger.warn(msg, data), + error: (msg, data) => this.logger.error(msg, data), + }, + }); + + try { + // 7. Run device sync definition → get raw device data + const syncDefinition = + dynamicIntegration.deviceSyncDefinition as unknown as SyncDefinition; + const syncRunner = interpretDeclarativeSync({ + definition: syncDefinition, + }); + + const rawDevices = await syncRunner.run(ctx); + + const validDevices: import('@trycompai/integration-platform').SyncDevice[] = []; + for (const raw of rawDevices) { + const parsed = SyncDeviceSchema.safeParse(raw); + if (parsed.success) { + validDevices.push(parsed.data); + } else { + this.logger.warn( + `[DeviceSync] Skipping invalid device: ${JSON.stringify(parsed.error.issues)}`, + ); + } + } + + this.logger.log( + `[DeviceSync] Sync definition produced ${rawDevices.length} raw devices, ${validDevices.length} valid for "${providerSlug}"`, + ); + + // 8. Process devices via generic service + const result = await this.genericDeviceSyncService.processDevices({ + organizationId, + connectionId, + devices: validDevices, + options: { providerName: manifest.name }, + }); + + // 9. Persist execution logs + results to the run record + const executionLogs = getResults().logs.map((log) => ({ + level: log.level, + message: log.message, + ...(log.data ? { data: log.data } : {}), + timestamp: log.timestamp.toISOString(), + })); + + const startTime = syncRun.startedAt?.getTime() || Date.now(); + await this.checkRunRepo.complete(syncRun.id, { + status: result.errors > 0 ? 'failed' : 'success', + durationMs: Date.now() - startTime, + totalChecked: result.totalFound, + passedCount: result.imported + result.updated, + failedCount: result.errors, + logs: + executionLogs.length > 0 + ? (executionLogs as unknown as Prisma.InputJsonValue) + : undefined, + }); + + this.logger.log( + `[DeviceSync] Sync complete for "${providerSlug}": imported=${result.imported} updated=${result.updated} removed=${result.removed} skipped=${result.skipped} errors=${result.errors}`, + ); + + return { + ...result, + syncRunId: syncRun.id, + }; + } catch (error) { + // Persist error + whatever logs were captured before the failure + const executionLogs = getResults().logs.map((log) => ({ + level: log.level, + message: log.message, + ...(log.data ? { data: log.data } : {}), + timestamp: log.timestamp.toISOString(), + })); + + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + const startTime = syncRun.startedAt?.getTime() || Date.now(); + await this.checkRunRepo.complete(syncRun.id, { + status: 'failed', + durationMs: Date.now() - startTime, + totalChecked: 0, + passedCount: 0, + failedCount: 0, + errorMessage, + logs: [ + ...executionLogs, + { + level: 'error', + message: `Device sync execution failed: ${errorMessage}`, + ...(errorStack ? { data: { stack: errorStack } } : {}), + timestamp: new Date().toISOString(), + }, + ] as unknown as Prisma.InputJsonValue, + }); + + this.logger.error( + `[DeviceSync] Sync failed for "${providerSlug}": ${errorMessage}`, + ); + + throw new HttpException( + { + message: `Device sync execution failed: ${errorMessage}`, + syncRunId: syncRun.id, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index 36e52e415d..4ed3260faa 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -31,6 +31,7 @@ import { DynamicIntegrationRepository } from './repositories/dynamic-integration import { DynamicCheckRepository } from './repositories/dynamic-check.repository'; import { IntegrationSyncLoggerService } from './services/integration-sync-logger.service'; import { GenericEmployeeSyncService } from './services/generic-employee-sync.service'; +import { GenericDeviceSyncService } from './services/generic-device-sync.service'; @Module({ imports: [AuthModule, forwardRef(() => CloudSecurityModule)], @@ -59,6 +60,7 @@ import { GenericEmployeeSyncService } from './services/generic-employee-sync.ser TaskIntegrationChecksService, IntegrationSyncLoggerService, GenericEmployeeSyncService, + GenericDeviceSyncService, // Repositories ProviderRepository, ConnectionRepository, diff --git a/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts b/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts index 3f28cc5125..e45d2eb175 100644 --- a/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts +++ b/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts @@ -56,6 +56,7 @@ export class DynamicIntegrationRepository { capabilities?: Prisma.InputJsonValue; supportsMultipleConnections?: boolean; syncDefinition?: Prisma.InputJsonValue; + deviceSyncDefinition?: Prisma.InputJsonValue; services?: Prisma.InputJsonValue; }): Promise { return db.dynamicIntegration.create({ @@ -72,6 +73,7 @@ export class DynamicIntegrationRepository { capabilities: data.capabilities ?? ['checks'], supportsMultipleConnections: data.supportsMultipleConnections ?? false, syncDefinition: data.syncDefinition ?? undefined, + deviceSyncDefinition: data.deviceSyncDefinition ?? undefined, services: data.services ?? undefined, }, }); @@ -104,6 +106,7 @@ export class DynamicIntegrationRepository { capabilities?: Prisma.InputJsonValue; supportsMultipleConnections?: boolean; syncDefinition?: Prisma.InputJsonValue | null; + deviceSyncDefinition?: Prisma.InputJsonValue | null; services?: Prisma.InputJsonValue; }): Promise { return db.dynamicIntegration.upsert({ @@ -121,6 +124,7 @@ export class DynamicIntegrationRepository { capabilities: data.capabilities ?? ['checks'], supportsMultipleConnections: data.supportsMultipleConnections ?? false, syncDefinition: data.syncDefinition ?? undefined, + deviceSyncDefinition: data.deviceSyncDefinition ?? undefined, services: data.services ?? undefined, }, update: { @@ -138,6 +142,10 @@ export class DynamicIntegrationRepository { data.syncDefinition === null ? Prisma.DbNull : (data.syncDefinition ?? undefined), + deviceSyncDefinition: + data.deviceSyncDefinition === null + ? Prisma.DbNull + : (data.deviceSyncDefinition ?? undefined), services: data.services ?? undefined, }, }); diff --git a/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts new file mode 100644 index 0000000000..0054374a9d --- /dev/null +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts @@ -0,0 +1,302 @@ +import type { SyncDevice } from '@trycompai/integration-platform'; + +const mockMemberFindFirst = jest.fn(); +const mockDeviceFindFirst = jest.fn(); +const mockDeviceCreate = jest.fn(); +const mockDeviceUpdate = jest.fn(); +const mockDeviceDeleteMany = jest.fn(); +const mockDeviceFindMany = jest.fn(); + +jest.mock('@db', () => ({ + db: { + member: { + findFirst: (...args: unknown[]) => mockMemberFindFirst(...args), + }, + device: { + findFirst: (...args: unknown[]) => mockDeviceFindFirst(...args), + create: (...args: unknown[]) => mockDeviceCreate(...args), + update: (...args: unknown[]) => mockDeviceUpdate(...args), + deleteMany: (...args: unknown[]) => mockDeviceDeleteMany(...args), + findMany: (...args: unknown[]) => mockDeviceFindMany(...args), + }, + }, +})); + +import { GenericDeviceSyncService } from './generic-device-sync.service'; + +describe('GenericDeviceSyncService', () => { + let service: GenericDeviceSyncService; + + const ORG_ID = 'org_1'; + const CONN_ID = 'conn_1'; + + const baseDevice = ( + overrides: Partial = {}, + ): SyncDevice => ({ + name: 'Test MacBook', + platform: 'macos', + serialNumber: 'SN-001', + userEmail: 'alice@example.com', + status: 'active', + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + service = new GenericDeviceSyncService(); + + // Defaults: member exists, no existing device, Phase 2 returns empty. + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_1', + organizationId: ORG_ID, + }); + mockDeviceFindFirst.mockResolvedValue(null); + mockDeviceCreate.mockResolvedValue({ id: 'dev_1' }); + mockDeviceUpdate.mockResolvedValue({ id: 'dev_1' }); + mockDeviceFindMany.mockResolvedValue([]); + mockDeviceDeleteMany.mockResolvedValue({ count: 0 }); + }); + + // ======================================================================== + // Phase 1 — Import + // ======================================================================== + + describe('Phase 1 — Import', () => { + it('creates a new device when member exists and device is new', async () => { + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + + expect(mockMemberFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + organizationId: ORG_ID, + deactivated: false, + }), + select: { id: true }, + }), + ); + + expect(mockDeviceCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: 'Test MacBook', + platform: 'macos', + serialNumber: 'SN-001', + memberId: 'mem_1', + organizationId: ORG_ID, + source: 'integration', + integrationConnectionId: CONN_ID, + }), + }), + ); + + expect(result.imported).toBe(1); + expect(result.totalFound).toBe(1); + expect(result.details).toContainEqual( + expect.objectContaining({ + status: 'imported', + }), + ); + }); + + it('updates an existing device matched by serial number', async () => { + mockDeviceFindFirst.mockResolvedValue({ + id: 'dev_existing', + serialNumber: 'SN-001', + organizationId: ORG_ID, + }); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [ + baseDevice({ + osVersion: '15.0', + hardwareModel: 'MacBookPro18,1', + }), + ], + }); + + expect(mockDeviceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev_existing' }, + data: expect.objectContaining({ + name: 'Test MacBook', + osVersion: '15.0', + hardwareModel: 'MacBookPro18,1', + source: 'integration', + integrationConnectionId: CONN_ID, + }), + }), + ); + + expect(mockDeviceCreate).not.toHaveBeenCalled(); + expect(result.updated).toBe(1); + expect(result.imported).toBe(0); + }); + + it('updates memberId when device ownership changes', async () => { + mockDeviceFindFirst.mockResolvedValue({ + id: 'dev_existing', + serialNumber: 'SN-001', + organizationId: ORG_ID, + }); + mockMemberFindFirst.mockResolvedValue({ id: 'mem_new_owner' }); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + + expect(mockDeviceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev_existing' }, + data: expect.objectContaining({ + memberId: 'mem_new_owner', + }), + }), + ); + expect(result.updated).toBe(1); + }); + + it('skips devices when no matching member exists', async () => { + mockMemberFindFirst.mockResolvedValue(null); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ userEmail: 'unknown@example.com' })], + }); + + expect(mockDeviceCreate).not.toHaveBeenCalled(); + expect(mockDeviceUpdate).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.details).toContainEqual( + expect.objectContaining({ + status: 'skipped', + reason: expect.stringContaining('member'), + }), + ); + }); + + it('only processes devices with status active', async () => { + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [ + baseDevice({ status: 'active', serialNumber: 'SN-ACTIVE' }), + baseDevice({ status: 'inactive', serialNumber: 'SN-INACTIVE' }), + ], + }); + + // Only the active device should trigger a member lookup + create + expect(mockMemberFindFirst).toHaveBeenCalledTimes(1); + expect(result.imported).toBe(1); + expect(result.totalFound).toBe(2); + }); + }); + + // ======================================================================== + // Phase 2 — Remove disappeared + // ======================================================================== + + describe('Phase 2 — Remove disappeared', () => { + it('deletes devices from this connection that are no longer in the sync result', async () => { + // Phase 2: existing devices in DB for this connection + mockDeviceFindMany.mockResolvedValue([ + { + id: 'dev_old', + serialNumber: 'SN-OLD', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + { + id: 'dev_current', + serialNumber: 'SN-001', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + ]); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ serialNumber: 'SN-001' })], + }); + + expect(mockDeviceDeleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['dev_old'] } }, + }); + expect(result.removed).toBe(1); + }); + + it('should NOT delete existing devices when all sync devices were skipped (member not found)', async () => { + mockMemberFindFirst.mockResolvedValue(null); + mockDeviceFindMany.mockResolvedValue([ + { + id: 'dev_existing', + serialNumber: 'SN-001', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + ]); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ serialNumber: 'SN-001' })], + }); + + // Device was skipped because member doesn't exist, but its identifier + // is still tracked so Phase 2 won't remove it from the DB. + expect(result.skipped).toBe(1); + expect(result.removed).toBe(0); + expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); + }); + + it('should skip Phase 2 when sync payload contains only inactive devices', async () => { + mockDeviceFindMany.mockResolvedValue([ + { + id: 'dev_existing', + serialNumber: 'EXISTING', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + ]); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ status: 'inactive', serialNumber: 'SN-INACTIVE' })], + }); + + // No active devices means no identifiers tracked → Phase 2 guard skips removal + expect(result.removed).toBe(0); + expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); + }); + + it('does NOT delete devices that are still in the sync result', async () => { + mockDeviceFindMany.mockResolvedValue([ + { + id: 'dev_current', + serialNumber: 'SN-001', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + ]); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ serialNumber: 'SN-001' })], + }); + + expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); + expect(result.removed).toBe(0); + }); + }); +}); diff --git a/apps/api/src/integration-platform/services/generic-device-sync.service.ts b/apps/api/src/integration-platform/services/generic-device-sync.service.ts new file mode 100644 index 0000000000..e86421744f --- /dev/null +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -0,0 +1,287 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { db } from '@db'; +import type { SyncDevice } from '@trycompai/integration-platform'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface DeviceSyncResultDetail { + identifier: string; + status: 'imported' | 'updated' | 'skipped' | 'removed' | 'error'; + reason?: string; +} + +export interface DeviceSyncResult { + success: boolean; + totalFound: number; + imported: number; + updated: number; + skipped: number; + removed: number; + errors: number; + details: DeviceSyncResultDetail[]; +} + +interface SyncedIdentifier { + serialNumber?: string; + externalId?: string; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Generic device sync service that handles platform-generic operations: + * - Creating or updating Device records from a standardized device list + * - Removing devices no longer present in the provider + * + * Mirrors GenericEmployeeSyncService but for the device import flow. + */ +@Injectable() +export class GenericDeviceSyncService { + private readonly logger = new Logger(GenericDeviceSyncService.name); + + async processDevices({ + organizationId, + connectionId, + devices, + options = {}, + }: { + organizationId: string; + connectionId: string; + devices: SyncDevice[]; + options?: { providerName?: string }; + }): Promise { + const providerName = options.providerName ?? 'provider'; + + const result: DeviceSyncResult = { + success: true, + totalFound: devices.length, + imported: 0, + updated: 0, + skipped: 0, + removed: 0, + errors: 0, + details: [], + }; + + this.logger.log( + `[DeviceSync] Processing ${devices.length} devices for org="${organizationId}" provider="${providerName}"`, + ); + + const activeDevices = devices.filter((d) => d.status === 'active'); + const syncedIdentifiers: SyncedIdentifier[] = []; + + // ================================================================== + // Phase 1: Import active devices + // ================================================================== + for (const device of activeDevices) { + const identifier = + device.serialNumber ?? device.externalId ?? device.name; + + try { + const normalizedEmail = device.userEmail.toLowerCase(); + + // Track ALL sync identifiers for Phase 2 (even if member doesn't exist yet) + syncedIdentifiers.push({ + serialNumber: device.serialNumber, + externalId: device.externalId, + }); + + // Find member by email in this org + const member = await db.member.findFirst({ + where: { + organizationId, + deactivated: false, + user: { email: normalizedEmail }, + }, + select: { id: true }, + }); + + if (!member) { + result.skipped++; + result.details.push({ + identifier, + status: 'skipped', + reason: `No matching member for email ${normalizedEmail}`, + }); + continue; + } + + // Find existing device — serialNumber match takes priority + let existingDevice: { id: string } | null = null; + if (device.serialNumber) { + existingDevice = await db.device.findFirst({ + where: { + serialNumber: device.serialNumber, + organizationId, + }, + select: { id: true }, + }); + } + if (!existingDevice && device.externalId) { + existingDevice = await db.device.findFirst({ + where: { + externalDeviceId: device.externalId, + integrationConnectionId: connectionId, + }, + select: { id: true }, + }); + } + + const updateData = { + name: device.name, + hostname: device.hostname ?? device.name, + platform: device.platform, + osVersion: device.osVersion ?? 'Unknown', + hardwareModel: device.hardwareModel, + lastCheckIn: new Date(), + source: 'integration' as const, + integrationConnectionId: connectionId, + externalDeviceId: device.externalId, + }; + + if (existingDevice) { + await db.device.update({ + where: { id: existingDevice.id }, + data: { ...updateData, memberId: member.id }, + }); + result.updated++; + result.details.push({ identifier, status: 'updated' }); + } else { + try { + await db.device.create({ + data: { + ...updateData, + serialNumber: device.serialNumber, + memberId: member.id, + organizationId, + }, + }); + result.imported++; + result.details.push({ identifier, status: 'imported' }); + } catch (createError) { + if ( + createError instanceof Prisma.PrismaClientKnownRequestError && + createError.code === 'P2002' + ) { + this.logger.warn( + `[DeviceSync] Unique constraint hit for ${identifier} — falling back to update`, + ); + const conflicting = await db.device.findFirst({ + where: { + serialNumber: device.serialNumber, + organizationId, + }, + select: { id: true }, + }); + if (conflicting) { + await db.device.update({ + where: { id: conflicting.id }, + data: updateData, + }); + result.updated++; + result.details.push({ identifier, status: 'updated' }); + } + } else { + throw createError; + } + } + } + } catch (error) { + this.logger.error( + `Error processing device ${identifier}: ${error}`, + ); + result.errors++; + result.details.push({ + identifier, + status: 'error', + reason: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + this.logger.log( + `[DeviceSync] Phase 1 complete: imported=${result.imported} updated=${result.updated} skipped=${result.skipped} errors=${result.errors}`, + ); + + // ================================================================== + // Phase 2: Remove disappeared devices + // ================================================================== + + // Only run removal if we actually processed at least one device successfully + if (syncedIdentifiers.length === 0) { + this.logger.log( + '[DeviceSync] No devices successfully processed — skipping Phase 2 removal', + ); + } else { + const existingIntegrationDevices = await db.device.findMany({ + where: { + organizationId, + integrationConnectionId: connectionId, + source: 'integration', + }, + select: { + id: true, + serialNumber: true, + externalDeviceId: true, + }, + }); + + const syncedSerials = new Set( + syncedIdentifiers + .map((s) => s.serialNumber) + .filter((v): v is string => Boolean(v)), + ); + const syncedExternalIds = new Set( + syncedIdentifiers + .map((s) => s.externalId) + .filter((v): v is string => Boolean(v)), + ); + + const toRemove = existingIntegrationDevices.filter((d) => { + const matchedBySerial = + d.serialNumber && syncedSerials.has(d.serialNumber); + const matchedByExternal = + d.externalDeviceId && syncedExternalIds.has(d.externalDeviceId); + return !matchedBySerial && !matchedByExternal; + }); + + if (toRemove.length > 0) { + const idsToDelete = toRemove.map((d) => d.id); + + try { + await db.device.deleteMany({ + where: { id: { in: idsToDelete } }, + }); + result.removed = toRemove.length; + + for (const device of toRemove) { + result.details.push({ + identifier: + device.serialNumber ?? device.externalDeviceId ?? device.id, + status: 'removed', + reason: `Device no longer reported by ${providerName}`, + }); + } + } catch (deleteError) { + this.logger.error( + `[DeviceSync] Failed to delete ${idsToDelete.length} devices: ${deleteError}`, + ); + result.errors += idsToDelete.length; + } + } + } + + result.success = result.errors === 0; + + this.logger.log( + `[DeviceSync] Sync complete for ${providerName}: ${result.imported} imported, ${result.updated} updated, ${result.removed} removed, ${result.skipped} skipped, ${result.errors} errors`, + ); + + return result; + } +} diff --git a/apps/api/src/trigger/integration-platform/run-device-sync.ts b/apps/api/src/trigger/integration-platform/run-device-sync.ts new file mode 100644 index 0000000000..4be301a01d --- /dev/null +++ b/apps/api/src/trigger/integration-platform/run-device-sync.ts @@ -0,0 +1,90 @@ +import { logger, tags, task } from '@trigger.dev/sdk'; + +const API_BASE_URL = process.env.BASE_URL || 'http://localhost:3333'; + +/** + * Trigger.dev task that runs device sync for a single org+connection. + * Calls the existing API endpoint which handles credential refresh, + * DSL interpretation, and device processing. + * + * Triggered by the daily integration-checks-schedule orchestrator. + */ +export const runDeviceSync = task({ + id: 'run-device-sync', + maxDuration: 1000 * 60 * 10, // 10 minutes + run: async (payload: { + organizationId: string; + connectionId: string; + providerSlug: string; + }) => { + const { organizationId, connectionId, providerSlug } = payload; + + await tags.add([`org:${organizationId}`]); + + logger.info(`Starting device sync for provider "${providerSlug}"`, { + connectionId, + organizationId, + }); + + try { + const url = new URL( + `${API_BASE_URL}/v1/integrations/sync/dynamic/${providerSlug}/devices`, + ); + url.searchParams.set('connectionId', connectionId); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, + 'x-organization-id': organizationId, + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error( + `Device sync API call failed: ${response.status} - ${errorBody}`, + ); + + return { + success: false, + error: `Device sync failed: ${response.status} - ${errorBody}`, + }; + } + + const result = (await response.json()) as { + success: boolean; + totalFound: number; + imported: number; + updated: number; + removed: number; + skipped: number; + errors: number; + syncRunId?: string; + }; + + logger.info(`Device sync completed for "${providerSlug}"`, { + imported: result.imported, + updated: result.updated, + removed: result.removed, + skipped: result.skipped, + errors: result.errors, + }); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + logger.error(`Device sync failed for "${providerSlug}"`, { + error: errorMessage, + }); + + return { + success: false, + error: errorMessage, + }; + } + }, +}); diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts index 9343a609c9..6e7e55d22b 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts @@ -34,6 +34,10 @@ jest.mock('./run-task-integration-checks', () => ({ runTaskIntegrationChecks: { batchTrigger: jest.fn() }, })); +jest.mock('./run-device-sync', () => ({ + runDeviceSync: { trigger: jest.fn() }, +})); + const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); describe('filterDueTasks (integration orchestrator)', () => { diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index 600e03ef94..3b06af4cfe 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -2,6 +2,7 @@ import { getManifest } from '@trycompai/integration-platform'; import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runTaskIntegrationChecks } from './run-task-integration-checks'; +import { runDeviceSync } from './run-device-sync'; import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks'; import { isDueToday } from '../shared/is-due-today'; @@ -54,11 +55,6 @@ export const integrationChecksSchedule = schedules.task({ }, }); - if (activeConnections.length === 0) { - logger.info('No active integration connections found'); - return { success: true, tasksTriggered: 0 }; - } - logger.info(`Found ${activeConnections.length} active connections`); // For each connection, find tasks that have checks mapped to them @@ -143,48 +139,86 @@ export const integrationChecksSchedule = schedules.task({ } } + // Trigger integration checks in batches + let totalTriggered = 0; + if (tasksToRun.length === 0) { logger.info('No tasks with mapped integration checks found'); - return { success: true, tasksTriggered: 0 }; + } else { + logger.info( + `Found ${tasksToRun.length} tasks with integration checks to run`, + ); + + const BATCH_SIZE = 500; + const triggerPayloads = tasksToRun.map((t) => ({ payload: t })); + + try { + for (let i = 0; i < triggerPayloads.length; i += BATCH_SIZE) { + const batch = triggerPayloads.slice(i, i + BATCH_SIZE); + await runTaskIntegrationChecks.batchTrigger(batch); + totalTriggered += batch.length; + + logger.info( + `Triggered batch ${Math.floor(i / BATCH_SIZE) + 1}: ${batch.length} tasks`, + ); + } + + logger.info(`Triggered ${totalTriggered} task integration check runs`); + } catch (error) { + logger.error('Failed to trigger task integration checks', { + error: error instanceof Error ? error.message : String(error), + triggeredBeforeError: totalTriggered, + }); + } } - logger.info( - `Found ${tasksToRun.length} tasks with integration checks to run`, - ); + // === Device Sync === + // Find orgs with deviceSyncProvider set and trigger device sync + const orgsWithDeviceSync = await db.organization.findMany({ + where: { deviceSyncProvider: { not: null } }, + select: { id: true, deviceSyncProvider: true }, + }); - // Trigger in batches of 500 - const BATCH_SIZE = 500; - const triggerPayloads = tasksToRun.map((t) => ({ payload: t })); - let totalTriggered = 0; + let deviceSyncsTriggered = 0; - try { - for (let i = 0; i < triggerPayloads.length; i += BATCH_SIZE) { - const batch = triggerPayloads.slice(i, i + BATCH_SIZE); - await runTaskIntegrationChecks.batchTrigger(batch); - totalTriggered += batch.length; + for (const org of orgsWithDeviceSync) { + const connection = await db.integrationConnection.findFirst({ + where: { + organizationId: org.id, + status: 'active', + provider: { slug: org.deviceSyncProvider! }, + }, + select: { id: true }, + }); - logger.info( - `Triggered batch ${Math.floor(i / BATCH_SIZE) + 1}: ${batch.length} tasks`, + if (!connection) { + logger.warn( + `No active connection for device sync provider ${org.deviceSyncProvider} in org ${org.id}`, ); + continue; } - logger.info(`Triggered ${totalTriggered} task integration check runs`); + try { + await runDeviceSync.trigger({ + organizationId: org.id, + connectionId: connection.id, + providerSlug: org.deviceSyncProvider!, + }); + deviceSyncsTriggered++; + } catch (error) { + logger.error( + `Failed to trigger device sync for org ${org.id}`, + { error: error instanceof Error ? error.message : String(error) }, + ); + } + } - return { - success: true, - tasksTriggered: totalTriggered, - }; - } catch (error) { - logger.error('Failed to trigger task integration checks', { - error: error instanceof Error ? error.message : String(error), - triggeredBeforeError: totalTriggered, - }); + logger.info(`Triggered ${deviceSyncsTriggered} device syncs`); - return { - success: false, - tasksTriggered: totalTriggered, - error: error instanceof Error ? error.message : String(error), - }; - } + return { + success: true, + tasksTriggered: totalTriggered, + deviceSyncsTriggered, + }; }, }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx new file mode 100644 index 0000000000..ba204b74d6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { Button, Skeleton } from '@trycompai/design-system'; +import { Renew } from '@trycompai/design-system/icons'; +import { useDeviceSync } from '../hooks/useDeviceSync'; + +export function DeviceSyncProviderSelector() { + const { orgId } = useParams<{ orgId: string }>(); + const { + selectedProvider, + isSyncing, + isLoading, + availableProviders, + syncDevices, + setSyncProvider, + getProviderName, + getProviderLogo, + hasAnyConnection, + } = useDeviceSync({ organizationId: orgId }); + + if (isLoading) { + return ; + } + + if (!hasAnyConnection) { + return null; + } + + const connectedProviders = availableProviders.filter((p) => p.connected); + + const handleSync = async () => { + if (!selectedProvider) return; + await syncDevices(selectedProvider); + }; + + const handleProviderChange = (e: React.ChangeEvent) => { + void setSyncProvider(e.target.value || null); + }; + + return ( +
+
+ {selectedProvider ? ( + <> + +
+
+ {getProviderName(selectedProvider)} +
+ {(() => { + const info = availableProviders.find( + (p) => p.slug === selectedProvider, + ); + if (!info?.lastSyncAt) return null; + const lastSync = new Date(info.lastSyncAt); + return ( +
+ Last synced{' '} + {lastSync.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} +
+ ); + })()} +
+ + ) : ( +
+ Select an integration to sync devices +
+ )} +
+ +
+ {connectedProviders.length > 1 || !selectedProvider ? ( + + ) : null} + + {selectedProvider && ( + + )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx index be7478e1c7..c4f8659c9c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx @@ -6,6 +6,7 @@ import { useAgentDevices } from '../hooks/useAgentDevices'; import { useFleetHosts } from '../hooks/useFleetHosts'; import { DeviceAgentDevicesList } from './DeviceAgentDevicesList'; import { DeviceComplianceChart } from './DeviceComplianceChart'; +import { DeviceSyncProviderSelector } from './DeviceSyncProviderSelector'; import { EmployeeDevicesList } from './EmployeeDevicesList'; interface DevicesTabContentProps { @@ -60,6 +61,7 @@ export function DevicesTabContent({ isCurrentUserOwner }: DevicesTabContentProps return (
+ Promise; + setSyncProvider: (provider: string | null) => Promise; + getProviderName: (provider: string) => string; + getProviderLogo: (provider: string) => string; + hasAnyConnection: boolean; +} + +export function useDeviceSync({ organizationId }: UseDeviceSyncOptions): UseDeviceSyncReturn { + const [isSyncing, setIsSyncing] = useState(false); + + // Fetch current device sync provider + const { data: providerData, mutate: mutateProvider } = useSWR<{ provider: string | null }>( + `/v1/integrations/sync/device-sync-provider?organizationId=${organizationId}`, + async (url: string) => { + const res = await apiClient.get<{ provider: string | null }>(url); + if (res.error) throw new Error(res.error); + return res.data as { provider: string | null }; + }, + ); + + // Fetch available device sync providers + const { data: availableData, isLoading } = useSWR<{ providers: DeviceSyncProviderInfo[] }>( + `/v1/integrations/sync/available-providers?organizationId=${organizationId}&syncType=device`, + async (url: string) => { + const res = await apiClient.get<{ providers: DeviceSyncProviderInfo[] }>(url); + if (res.error) throw new Error(res.error); + return res.data as { providers: DeviceSyncProviderInfo[] }; + }, + ); + + const selectedProvider = providerData?.provider ?? null; + const availableProviders = Array.isArray(availableData?.providers) + ? availableData.providers + : []; + + const getProviderName = (provider: string): string => { + return availableProviders.find((p) => p.slug === provider)?.name ?? provider; + }; + + const getProviderLogo = (provider: string): string => { + return availableProviders.find((p) => p.slug === provider)?.logoUrl ?? ''; + }; + + const setSyncProvider = async (provider: string | null) => { + try { + const response = await apiClient.post( + `/v1/integrations/sync/device-sync-provider?organizationId=${organizationId}`, + { provider }, + ); + + if (response.error) { + toast.error('Failed to set device sync provider'); + return; + } + + mutateProvider({ provider }, false); + + if (provider) { + const name = getProviderName(provider); + toast.success(`${name} set as your device sync provider`); + } + } catch { + toast.error('Failed to set device sync provider'); + } + }; + + const syncDevices = async (provider: string): Promise => { + const providerInfo = availableProviders.find((p) => p.slug === provider); + const connId = providerInfo?.connectionId; + + if (!connId) { + toast.error(`${getProviderName(provider)} is not connected`); + return null; + } + + setIsSyncing(true); + const providerName = getProviderName(provider); + + try { + if (selectedProvider !== provider) { + await setSyncProvider(provider); + } + + const response = await apiClient.post( + `/v1/integrations/sync/dynamic/${provider}/devices?organizationId=${organizationId}&connectionId=${connId}`, + ); + + if (response.data?.success) { + const { imported, updated, removed, skipped, errors } = response.data; + const parts: string[] = []; + if (imported > 0) parts.push(`${imported} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (removed > 0) parts.push(`${removed} removed`); + if (skipped > 0) parts.push(`${skipped} skipped`); + + if (parts.length > 0) { + toast.success(`Synced ${response.data.totalFound} devices — ${parts.join(', ')}`); + } else { + toast.info('All devices are already synced'); + } + + if (errors > 0) { + toast.warning(`${errors} device${errors > 1 ? 's' : ''} failed to sync`); + } + + return response.data; + } + + if (response.error) { + toast.error(response.error); + } + + return null; + } catch { + toast.error(`Failed to sync devices from ${providerName}`); + return null; + } finally { + setIsSyncing(false); + } + }; + + return { + selectedProvider, + isSyncing, + isLoading, + availableProviders, + syncDevices, + setSyncProvider, + getProviderName, + getProviderLogo, + hasAnyConnection: availableProviders.some((p) => p.connected), + }; +} diff --git a/packages/db/prisma/migrations/20260508100534_add_device_sync_fields/migration.sql b/packages/db/prisma/migrations/20260508100534_add_device_sync_fields/migration.sql new file mode 100644 index 0000000000..a4d0b2917b --- /dev/null +++ b/packages/db/prisma/migrations/20260508100534_add_device_sync_fields/migration.sql @@ -0,0 +1,16 @@ +-- CreateEnum +CREATE TYPE "DeviceSource" AS ENUM ('agent', 'fleet', 'integration'); + +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "externalDeviceId" TEXT, +ADD COLUMN "integrationConnectionId" TEXT, +ADD COLUMN "source" "DeviceSource" NOT NULL DEFAULT 'agent'; + +-- AlterTable +ALTER TABLE "DynamicIntegration" ADD COLUMN "deviceSyncDefinition" JSONB; + +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "deviceSyncProvider" TEXT; + +-- CreateIndex +CREATE INDEX "Device_integrationConnectionId_idx" ON "Device"("integrationConnectionId"); diff --git a/packages/db/prisma/schema/device.prisma b/packages/db/prisma/schema/device.prisma index 3a609c3eb7..b2c97ff8a7 100644 --- a/packages/db/prisma/schema/device.prisma +++ b/packages/db/prisma/schema/device.prisma @@ -29,11 +29,16 @@ model Device { findings Finding[] + source DeviceSource @default(agent) + integrationConnectionId String? + externalDeviceId String? + @@unique([serialNumber, organizationId]) @@index([memberId]) @@index([organizationId]) @@index([isCompliant]) @@index([agentSessionId]) + @@index([integrationConnectionId]) } enum DevicePlatform { @@ -41,3 +46,9 @@ enum DevicePlatform { windows linux } + +enum DeviceSource { + agent + fleet + integration +} diff --git a/packages/db/prisma/schema/dynamic-integration.prisma b/packages/db/prisma/schema/dynamic-integration.prisma index 58163d17de..89b9a35838 100644 --- a/packages/db/prisma/schema/dynamic-integration.prisma +++ b/packages/db/prisma/schema/dynamic-integration.prisma @@ -36,6 +36,10 @@ model DynamicIntegration { /// When present and capabilities includes 'sync', enables employee sync syncDefinition Json? + /// Declarative device sync definition (JSON — DSL steps that produce device list) + /// When present and capabilities includes 'device_sync', enables device sync + deviceSyncDefinition Json? + /// Services metadata (JSON array of { id, name, description, enabledByDefault?, implemented? }) services Json? diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 04e9bc24cd..f7659402d4 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -25,6 +25,10 @@ model Organization { // When set, the scheduled sync will only use this provider employeeSyncProvider String? + // Device sync provider (e.g., 'jamf', 'kandji') + // When set, the scheduled sync will import devices from this provider + deviceSyncProvider String? + apiKeys ApiKey[] auditLog AuditLog[] controls Control[] diff --git a/packages/integration-platform/src/dsl/index.ts b/packages/integration-platform/src/dsl/index.ts index 4a3544dcbc..97350f3c21 100644 --- a/packages/integration-platform/src/dsl/index.ts +++ b/packages/integration-platform/src/dsl/index.ts @@ -16,6 +16,7 @@ export type { CodeStep, CheckDefinition, SyncEmployee, + SyncDevice, SyncDefinition, Condition, FieldCondition, @@ -31,6 +32,7 @@ export { DSLStepSchema, CheckDefinitionSchema, SyncEmployeeSchema, + SyncDeviceSchema, SyncDefinitionSchema, DynamicIntegrationDefinitionSchema, ConditionSchema, diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index aabbbde8a3..b3fbffeb23 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -294,6 +294,25 @@ export const SyncDefinitionSchema = z.object({ export type SyncDefinition = z.infer; +// ============================================================================ +// Sync Device Schema (for dynamic device sync) +// ============================================================================ + +export const SyncDeviceSchema = z.object({ + name: z.string(), + platform: z.enum(['macos', 'windows', 'linux']), + serialNumber: z.string().optional(), + hostname: z.string().optional(), + osVersion: z.string().optional(), + hardwareModel: z.string().optional(), + userEmail: z.string(), + status: z.enum(['active', 'inactive']), + externalId: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type SyncDevice = z.infer; + // ============================================================================ // Dynamic Integration Definition (full manifest + checks as JSON) // ============================================================================ diff --git a/packages/integration-platform/src/index.ts b/packages/integration-platform/src/index.ts index 42382cd7a5..004f9801c8 100644 --- a/packages/integration-platform/src/index.ts +++ b/packages/integration-platform/src/index.ts @@ -107,6 +107,7 @@ export { validateIntegrationDefinition, CheckDefinitionSchema, SyncEmployeeSchema, + SyncDeviceSchema, SyncDefinitionSchema, DynamicIntegrationDefinitionSchema, ConditionSchema, @@ -119,6 +120,7 @@ export type { CodeStep, CheckDefinition, SyncEmployee, + SyncDevice, SyncDefinition, Condition, DynamicIntegrationDefinition, diff --git a/packages/integration-platform/src/types.ts b/packages/integration-platform/src/types.ts index cf12c0e19f..fb37365a39 100644 --- a/packages/integration-platform/src/types.ts +++ b/packages/integration-platform/src/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { SyncDefinition } from './dsl/types'; import type { TaskTemplateId } from './task-mappings'; // ============================================================================ @@ -208,7 +209,7 @@ export type CredentialField = z.infer; // Integration Capabilities // ============================================================================ -export type IntegrationCapability = 'checks' | 'webhook' | 'sync'; +export type IntegrationCapability = 'checks' | 'webhook' | 'sync' | 'device_sync'; export const WebhookConfigSchema = z.object({ /** Webhook endpoint path suffix */ @@ -836,6 +837,9 @@ export interface IntegrationManifest { /** Runtime handler for webhooks */ handler?: IntegrationHandler; + /** Declarative device sync definition (same DSL as employee sync) */ + deviceSyncDefinition?: SyncDefinition; + /** Whether multiple connections per org are allowed */ supportsMultipleConnections?: boolean;