From b9e7188e2ddad66f780237e52020b994171a126d Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:05:50 +0100 Subject: [PATCH 01/14] feat(db): add device sync fields to Device, Organization, and DynamicIntegration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 16 ++++++++++++++++ packages/db/prisma/schema/device.prisma | 11 +++++++++++ .../db/prisma/schema/dynamic-integration.prisma | 4 ++++ packages/db/prisma/schema/organization.prisma | 4 ++++ 4 files changed, 35 insertions(+) create mode 100644 packages/db/prisma/migrations/20260508100534_add_device_sync_fields/migration.sql 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[] From 31975ff3c4af28d41d51fe9b676ef0deaa42e2e6 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:07:56 +0100 Subject: [PATCH 02/14] feat(integration-platform): add device_sync capability and SyncDevice schema Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integration-platform/src/dsl/index.ts | 2 ++ .../integration-platform/src/dsl/types.ts | 19 +++++++++++++++++++ packages/integration-platform/src/index.ts | 2 ++ packages/integration-platform/src/types.ts | 6 +++++- 4 files changed, 28 insertions(+), 1 deletion(-) 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 f49ce5f57f..41a047f723 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; From 4602f9978c3b4c5106e69a9f223bef39993ffa0d Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:31:54 +0100 Subject: [PATCH 03/14] feat(api): add GenericDeviceSyncService with tests Two-phase device sync: imports active devices (matching by member email, serial number, or external ID) and removes disappeared devices from the connection. Follows the same pattern as GenericEmployeeSyncService. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integration-platform.module.ts | 2 + .../generic-device-sync.service.spec.ts | 231 ++++++++++++++++ .../services/generic-device-sync.service.ts | 248 ++++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts create mode 100644 apps/api/src/integration-platform/services/generic-device-sync.service.ts 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/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..f67a2b2dc7 --- /dev/null +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts @@ -0,0 +1,231 @@ +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, + }), + }), + ); + + 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('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('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..f5c1c8a697 --- /dev/null +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -0,0 +1,248 @@ +import { Injectable, Logger } from '@nestjs/common'; +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(); + + // Find member by email in this org + const member = await db.member.findFirst({ + where: { + organizationId, + deactivated: false, + user: { email: normalizedEmail }, + }, + include: { user: true }, + }); + + if (!member) { + result.skipped++; + result.details.push({ + identifier, + status: 'skipped', + reason: `No matching member for email ${normalizedEmail}`, + }); + continue; + } + + // Track identifiers for Phase 2 + syncedIdentifiers.push({ + serialNumber: device.serialNumber, + externalId: device.externalId, + }); + + // 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 }, + }); + } + + if (existingDevice) { + await db.device.update({ + where: { id: existingDevice.id }, + data: { + name: device.name, + hostname: device.hostname ?? device.name, + platform: device.platform, + osVersion: device.osVersion ?? 'Unknown', + hardwareModel: device.hardwareModel, + lastCheckIn: new Date(), + source: 'integration', + integrationConnectionId: connectionId, + externalDeviceId: device.externalId, + }, + }); + result.updated++; + result.details.push({ identifier, status: 'updated' }); + } else { + await db.device.create({ + data: { + name: device.name, + hostname: device.hostname ?? device.name, + platform: device.platform, + serialNumber: device.serialNumber, + osVersion: device.osVersion ?? 'Unknown', + hardwareModel: device.hardwareModel, + memberId: member.id, + organizationId, + lastCheckIn: new Date(), + source: 'integration', + integrationConnectionId: connectionId, + externalDeviceId: device.externalId, + }, + }); + result.imported++; + result.details.push({ identifier, status: 'imported' }); + } + } 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 + // ================================================================== + 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); + 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}`, + }); + } + } + + 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; + } +} From 8a87d91f8937a7ad2917f300d4eb7d5add6a2553 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:39:55 +0100 Subject: [PATCH 04/14] fix(api): address code review issues in GenericDeviceSyncService - Guard Phase 2 deletion when no devices were successfully processed (prevents false deletes) - Handle P2002 unique constraint violation on device create with fallback to update - Replace `include: { user: true }` with `select: { id: true }` on member lookup - Wrap Phase 2 deleteMany in try/catch to prevent uncaught DB errors - Add test verifying no deletions occur when all devices are skipped Co-Authored-By: Claude Opus 4.6 (1M context) --- .../generic-device-sync.service.spec.ts | 23 +++ .../services/generic-device-sync.service.ts | 185 +++++++++++------- 2 files changed, 135 insertions(+), 73 deletions(-) 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 index f67a2b2dc7..8dcdbecedd 100644 --- 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 @@ -75,6 +75,7 @@ describe('GenericDeviceSyncService', () => { organizationId: ORG_ID, deactivated: false, }), + select: { id: true }, }), ); @@ -208,6 +209,28 @@ describe('GenericDeviceSyncService', () => { expect(result.removed).toBe(1); }); + it('should NOT delete existing devices when all sync devices were skipped', async () => { + mockMemberFindFirst.mockResolvedValue(null); + 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()], + }); + + expect(result.skipped).toBe(1); + expect(result.removed).toBe(0); + expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); + }); + it('does NOT delete devices that are still in the sync result', async () => { mockDeviceFindMany.mockResolvedValue([ { 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 index f5c1c8a697..816f8f57bb 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { db } from '@db'; import type { SyncDevice } from '@trycompai/integration-platform'; @@ -91,7 +92,7 @@ export class GenericDeviceSyncService { deactivated: false, user: { email: normalizedEmail }, }, - include: { user: true }, + select: { id: true }, }); if (!member) { @@ -131,42 +132,64 @@ export class GenericDeviceSyncService { }); } + 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: { - name: device.name, - hostname: device.hostname ?? device.name, - platform: device.platform, - osVersion: device.osVersion ?? 'Unknown', - hardwareModel: device.hardwareModel, - lastCheckIn: new Date(), - source: 'integration', - integrationConnectionId: connectionId, - externalDeviceId: device.externalId, - }, + data: updateData, }); result.updated++; result.details.push({ identifier, status: 'updated' }); } else { - await db.device.create({ - data: { - name: device.name, - hostname: device.hostname ?? device.name, - platform: device.platform, - serialNumber: device.serialNumber, - osVersion: device.osVersion ?? 'Unknown', - hardwareModel: device.hardwareModel, - memberId: member.id, - organizationId, - lastCheckIn: new Date(), - source: 'integration', - integrationConnectionId: connectionId, - externalDeviceId: device.externalId, - }, - }); - result.imported++; - result.details.push({ identifier, status: 'imported' }); + 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( @@ -188,52 +211,68 @@ export class GenericDeviceSyncService { // ================================================================== // Phase 2: Remove disappeared devices // ================================================================== - 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); - await db.device.deleteMany({ - where: { id: { in: idsToDelete } }, + // 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, + }, }); - 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}`, - }); + 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; + } } } From 7adc9317d9647d61f0078cb5130050591fd9d1c6 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:44:01 +0100 Subject: [PATCH 05/14] feat(api): add device sync discovery, provider selection, and trigger endpoints Add device sync endpoints to the SyncController: - GET device-sync-provider: read the configured device sync provider - POST device-sync-provider: set/clear the device sync provider with validation - GET available-providers?syncType=device: filter providers by device_sync capability - POST dynamic/:providerSlug/devices: run DSL-based device sync with schema validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controllers/sync.controller.ts | 317 +++++++++++++++++- 1 file changed, 314 insertions(+), 3 deletions(-) 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, + ); + } + } } From 6c44c184b315bbdf7337e83faee491cf42e992a3 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:47:52 +0100 Subject: [PATCH 06/14] feat(api): validate deviceSyncDefinition on dynamic integration upsert Validate body.deviceSyncDefinition through SyncDefinitionSchema (applying defaults) and store it on both PUT upsert and POST create endpoints. Repository upsertBySlug and create methods updated to accept and pass through the new field. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dynamic-integrations.controller.ts | 21 +++++++++++++++++++ .../dynamic-integration.repository.ts | 8 +++++++ 2 files changed, 29 insertions(+) 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/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, }, }); From 6b1456f7778f788ce453ef7f0c5bdd35305594ae Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:52:58 +0100 Subject: [PATCH 07/14] feat(app): add device sync provider selector to Devices tab Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/DeviceSyncProviderSelector.tsx | 113 ++++++++++++ .../devices/components/DevicesTabContent.tsx | 2 + .../people/devices/hooks/useDeviceSync.ts | 163 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts 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 { + await apiClient.post( + `/v1/integrations/sync/device-sync-provider?organizationId=${organizationId}`, + { provider }, + ); + 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), + }; +} From 59c57dfbff5599e920cce8634fc7b2a53f6d8df5 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 12:08:06 +0100 Subject: [PATCH 08/14] feat(trigger): add scheduled device sync to daily integration checks Adds a new run-device-sync Trigger.dev task that calls the existing device sync API endpoint for a single org+connection. The daily integration-checks-schedule orchestrator now also finds orgs with deviceSyncProvider set and triggers device sync tasks for each. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integration-platform/run-device-sync.ts | 106 ++++++++++++++++++ .../run-integration-checks-schedule.spec.ts | 4 + .../run-integration-checks-schedule.ts | 106 ++++++++++++------ 3 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/trigger/integration-platform/run-device-sync.ts 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..a0e5d2e819 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/run-device-sync.ts @@ -0,0 +1,106 @@ +import { db } from '@db'; +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}`, + ); + + // Mark connection as error if credentials are invalid + if (response.status === 401) { + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { + status: 'error', + errorMessage: + 'Credentials expired during scheduled device sync. Please reconnect.', + }, + }); + } + + 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 { + success: true, + ...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, + }; }, }); From 7e1b680955603b5387348c71e78b37bab7c743a0 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 12:14:18 +0100 Subject: [PATCH 09/14] fix(trigger): remove duplicate success field in device sync task --- apps/api/src/trigger/integration-platform/run-device-sync.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/trigger/integration-platform/run-device-sync.ts b/apps/api/src/trigger/integration-platform/run-device-sync.ts index a0e5d2e819..264e938f7e 100644 --- a/apps/api/src/trigger/integration-platform/run-device-sync.ts +++ b/apps/api/src/trigger/integration-platform/run-device-sync.ts @@ -85,10 +85,7 @@ export const runDeviceSync = task({ errors: result.errors, }); - return { - success: true, - ...result, - }; + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); From 601f70a2d7be15ab3c3c8d70c60f10dacf2e92d8 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 12:36:04 +0100 Subject: [PATCH 10/14] fix(device-sync): address Cubic review feedback on device sync - Check apiClient error before updating state in setSyncProvider - Remove incorrect 401 connection error-marking in trigger task - Track all active device identifiers before member lookup in Phase 2 - Include memberId in device update for ownership changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../generic-device-sync.service.spec.ts | 54 +++++++++++++++++-- .../services/generic-device-sync.service.ts | 14 ++--- .../integration-platform/run-device-sync.ts | 13 ----- .../people/devices/hooks/useDeviceSync.ts | 8 ++- 4 files changed, 65 insertions(+), 24 deletions(-) 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 index 8dcdbecedd..0054374a9d 100644 --- 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 @@ -138,6 +138,31 @@ describe('GenericDeviceSyncService', () => { 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); @@ -209,12 +234,12 @@ describe('GenericDeviceSyncService', () => { expect(result.removed).toBe(1); }); - it('should NOT delete existing devices when all sync devices were skipped', async () => { + 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: 'EXISTING', + serialNumber: 'SN-001', externalDeviceId: null, integrationConnectionId: CONN_ID, }, @@ -223,14 +248,37 @@ describe('GenericDeviceSyncService', () => { const result = await service.processDevices({ organizationId: ORG_ID, connectionId: CONN_ID, - devices: [baseDevice()], + 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([ { 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 index 816f8f57bb..e86421744f 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -85,6 +85,12 @@ export class GenericDeviceSyncService { 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: { @@ -105,12 +111,6 @@ export class GenericDeviceSyncService { continue; } - // Track identifiers for Phase 2 - syncedIdentifiers.push({ - serialNumber: device.serialNumber, - externalId: device.externalId, - }); - // Find existing device — serialNumber match takes priority let existingDevice: { id: string } | null = null; if (device.serialNumber) { @@ -147,7 +147,7 @@ export class GenericDeviceSyncService { if (existingDevice) { await db.device.update({ where: { id: existingDevice.id }, - data: updateData, + data: { ...updateData, memberId: member.id }, }); result.updated++; result.details.push({ identifier, status: 'updated' }); diff --git a/apps/api/src/trigger/integration-platform/run-device-sync.ts b/apps/api/src/trigger/integration-platform/run-device-sync.ts index 264e938f7e..4be301a01d 100644 --- a/apps/api/src/trigger/integration-platform/run-device-sync.ts +++ b/apps/api/src/trigger/integration-platform/run-device-sync.ts @@ -1,4 +1,3 @@ -import { db } from '@db'; import { logger, tags, task } from '@trigger.dev/sdk'; const API_BASE_URL = process.env.BASE_URL || 'http://localhost:3333'; @@ -48,18 +47,6 @@ export const runDeviceSync = task({ `Device sync API call failed: ${response.status} - ${errorBody}`, ); - // Mark connection as error if credentials are invalid - if (response.status === 401) { - await db.integrationConnection.update({ - where: { id: connectionId }, - data: { - status: 'error', - errorMessage: - 'Credentials expired during scheduled device sync. Please reconnect.', - }, - }); - } - return { success: false, error: `Device sync failed: ${response.status} - ${errorBody}`, diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts index a8942c6edc..cad18f7bd8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts @@ -79,10 +79,16 @@ export function useDeviceSync({ organizationId }: UseDeviceSyncOptions): UseDevi const setSyncProvider = async (provider: string | null) => { try { - await apiClient.post( + 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) { From 68f8012b74fb4dee2da87326b707678bd1adb4aa Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 3 Jun 2026 12:10:29 -0400 Subject: [PATCH 11/14] fix(integration-platform): make dynamic device sync functional and safe Addresses the cubic review plus critical issues found in an adversarial review of PR #2802 (device import from integrations). Make it work: - device sync ran through the EMPLOYEE interpreter (SyncEmployeeSchema), so every device was dropped and nothing ever imported. Add a standalone interpretDeclarativeDeviceSync that resolves devicesPath and validates each item with SyncDeviceSchema; the controller now uses it (redundant re-validate removed). - allow authoring device-sync integrations: add the device_sync capability and deviceSyncDefinition (+ devicesPath) to the dynamic integration schema. Make it safe: - gate Phase 2 removal behind isDirectorySource (default false), mirroring the employee sync. A non-authoritative or partial provider response can no longer hard-delete devices (which cascade-delete their Findings). Hard delete stays behind the gate with a warning to convert it to a soft removal first. - Phase 1 and the P2002 fallback no longer hijack agent/Fleet devices that share a hardware serial; devices with no serialNumber and no externalId are skipped. Cubic findings: - maxDuration was in milliseconds (~7 days); Trigger.dev expects seconds. - DeviceSyncProviderSelector controls are gated on integration:update. - the P2002 fallback update now refreshes memberId on ownership change. UX: - partial-failure syncs now surface a toast instead of failing silently. - connection.lastSyncAt is recorded so the selector shows "Last synced". Tests: device interpreter (4), service gating/source-scoping/identifier guard, maxDuration regression, and RBAC component tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/sync.controller.ts | 35 +++--- .../generic-device-sync.service.spec.ts | 116 +++++++++++++++++- .../services/generic-device-sync.service.ts | 63 ++++++++-- .../run-device-sync.spec.ts | 21 ++++ .../integration-platform/run-device-sync.ts | 2 +- .../DeviceSyncProviderSelector.test.tsx | 84 +++++++++++++ .../components/DeviceSyncProviderSelector.tsx | 9 ++ .../people/devices/hooks/useDeviceSync.ts | 12 +- .../src/dsl/__tests__/interpreter.test.ts | 76 +++++++++++- .../integration-platform/src/dsl/index.ts | 6 +- .../src/dsl/interpreter.ts | 62 +++++++++- .../integration-platform/src/dsl/types.ts | 11 +- packages/integration-platform/src/index.ts | 1 + 13 files changed, 457 insertions(+), 41 deletions(-) create mode 100644 apps/api/src/trigger/integration-platform/run-device-sync.spec.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 714f053249..315084d49e 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -34,7 +34,7 @@ import { matchesSyncFilterTerms, parseSyncFilterTerms, interpretDeclarativeSync, - SyncDeviceSchema, + interpretDeclarativeDeviceSync, type OAuthConfig, type SyncDefinition, } from '@trycompai/integration-platform'; @@ -2159,29 +2159,17 @@ export class SyncController { }); try { - // 7. Run device sync definition → get raw device data + // 7. Run device sync definition → get validated device list const syncDefinition = dynamicIntegration.deviceSyncDefinition as unknown as SyncDefinition; - const syncRunner = interpretDeclarativeSync({ + const syncRunner = interpretDeclarativeDeviceSync({ 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)}`, - ); - } - } + const validDevices = await syncRunner.run(ctx); this.logger.log( - `[DeviceSync] Sync definition produced ${rawDevices.length} raw devices, ${validDevices.length} valid for "${providerSlug}"`, + `[DeviceSync] Device sync definition produced ${validDevices.length} valid devices for "${providerSlug}"`, ); // 8. Process devices via generic service @@ -2189,7 +2177,12 @@ export class SyncController { organizationId, connectionId, devices: validDevices, - options: { providerName: manifest.name }, + options: { + providerName: manifest.name, + isDirectorySource: + (syncDefinition as { isDirectorySource?: boolean }) + .isDirectorySource ?? false, + }, }); // 9. Persist execution logs + results to the run record @@ -2213,6 +2206,12 @@ export class SyncController { : undefined, }); + // Record that a device sync ran so the People → Devices selector can show + // "Last synced" (mirrors the employee sync path). + await this.connectionRepository.update(connectionId, { + lastSyncAt: new Date(), + }); + this.logger.log( `[DeviceSync] Sync complete for "${providerSlug}": imported=${result.imported} updated=${result.updated} removed=${result.removed} skipped=${result.skipped} errors=${result.errors}`, ); 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 index 0054374a9d..e02e2ec589 100644 --- 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 @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import type { SyncDevice } from '@trycompai/integration-platform'; const mockMemberFindFirst = jest.fn(); @@ -102,9 +103,10 @@ describe('GenericDeviceSyncService', () => { ); }); - it('updates an existing device matched by serial number', async () => { + it('updates an existing integration device matched by serial number', async () => { mockDeviceFindFirst.mockResolvedValue({ id: 'dev_existing', + source: 'integration', serialNumber: 'SN-001', organizationId: ORG_ID, }); @@ -141,6 +143,7 @@ describe('GenericDeviceSyncService', () => { it('updates memberId when device ownership changes', async () => { mockDeviceFindFirst.mockResolvedValue({ id: 'dev_existing', + source: 'integration', serialNumber: 'SN-001', organizationId: ORG_ID, }); @@ -163,6 +166,86 @@ describe('GenericDeviceSyncService', () => { expect(result.updated).toBe(1); }); + it('refreshes memberId on the P2002 unique-constraint fallback update', async () => { + // existingDevice lookup → null (forces a create), then the create hits a + // unique constraint and the conflicting-row lookup returns the existing device. + mockDeviceFindFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'dev_conflict', + source: 'integration', + serialNumber: 'SN-001', + organizationId: ORG_ID, + }); + mockMemberFindFirst.mockResolvedValue({ id: 'mem_new_owner' }); + mockDeviceCreate.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + }), + ); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + + // The fallback update must apply the current owner, not just static fields. + expect(mockDeviceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev_conflict' }, + data: expect.objectContaining({ memberId: 'mem_new_owner' }), + }), + ); + expect(result.updated).toBe(1); + }); + + it('skips a device whose serial is already managed by the agent (no hijack)', async () => { + mockDeviceFindFirst.mockResolvedValue({ + id: 'dev_agent', + source: 'agent', + serialNumber: 'SN-001', + organizationId: ORG_ID, + }); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + + expect(mockDeviceUpdate).not.toHaveBeenCalled(); + expect(mockDeviceCreate).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.details).toContainEqual( + expect.objectContaining({ + status: 'skipped', + reason: expect.stringContaining('agent'), + }), + ); + }); + + it('skips a device that has neither serialNumber nor externalId', async () => { + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [ + baseDevice({ serialNumber: undefined, externalId: undefined }), + ], + }); + + expect(mockMemberFindFirst).not.toHaveBeenCalled(); + expect(mockDeviceCreate).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.details).toContainEqual( + expect.objectContaining({ + status: 'skipped', + reason: expect.stringContaining('identifier'), + }), + ); + }); + it('skips devices when no matching member exists', async () => { mockMemberFindFirst.mockResolvedValue(null); @@ -201,12 +284,33 @@ describe('GenericDeviceSyncService', () => { }); // ======================================================================== - // Phase 2 — Remove disappeared + // Phase 2 — Remove disappeared (only when isDirectorySource = true) // ======================================================================== 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 + it('is skipped entirely when isDirectorySource is not set (default)', async () => { + mockDeviceFindMany.mockResolvedValue([ + { + id: 'dev_old', + serialNumber: 'SN-OLD', + externalDeviceId: null, + integrationConnectionId: CONN_ID, + }, + ]); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ serialNumber: 'SN-001' })], + }); + + // No pruning by default — a non-authoritative provider must never delete. + expect(mockDeviceFindMany).not.toHaveBeenCalled(); + expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); + expect(result.removed).toBe(0); + }); + + it('deletes stale devices when isDirectorySource = true', async () => { mockDeviceFindMany.mockResolvedValue([ { id: 'dev_old', @@ -226,6 +330,7 @@ describe('GenericDeviceSyncService', () => { organizationId: ORG_ID, connectionId: CONN_ID, devices: [baseDevice({ serialNumber: 'SN-001' })], + options: { isDirectorySource: true }, }); expect(mockDeviceDeleteMany).toHaveBeenCalledWith({ @@ -249,6 +354,7 @@ describe('GenericDeviceSyncService', () => { organizationId: ORG_ID, connectionId: CONN_ID, devices: [baseDevice({ serialNumber: 'SN-001' })], + options: { isDirectorySource: true }, }); // Device was skipped because member doesn't exist, but its identifier @@ -272,6 +378,7 @@ describe('GenericDeviceSyncService', () => { organizationId: ORG_ID, connectionId: CONN_ID, devices: [baseDevice({ status: 'inactive', serialNumber: 'SN-INACTIVE' })], + options: { isDirectorySource: true }, }); // No active devices means no identifiers tracked → Phase 2 guard skips removal @@ -293,6 +400,7 @@ describe('GenericDeviceSyncService', () => { organizationId: ORG_ID, connectionId: CONN_ID, devices: [baseDevice({ serialNumber: 'SN-001' })], + options: { isDirectorySource: true }, }); expect(mockDeviceDeleteMany).not.toHaveBeenCalled(); 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 index e86421744f..8b3af56b54 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -53,9 +53,10 @@ export class GenericDeviceSyncService { organizationId: string; connectionId: string; devices: SyncDevice[]; - options?: { providerName?: string }; + options?: { providerName?: string; isDirectorySource?: boolean }; }): Promise { const providerName = options.providerName ?? 'provider'; + const isDirectorySource = options.isDirectorySource ?? false; const result: DeviceSyncResult = { success: true, @@ -82,6 +83,18 @@ export class GenericDeviceSyncService { const identifier = device.serialNumber ?? device.externalId ?? device.name; + // A device with neither serialNumber nor externalId can never be matched + // on a later sync, so importing it would create an untrackable orphan. + if (!device.serialNumber && !device.externalId) { + result.skipped++; + result.details.push({ + identifier, + status: 'skipped', + reason: 'No stable identifier (serialNumber or externalId required)', + }); + continue; + } + try { const normalizedEmail = device.userEmail.toLowerCase(); @@ -112,23 +125,38 @@ export class GenericDeviceSyncService { } // Find existing device — serialNumber match takes priority - let existingDevice: { id: string } | null = null; + let existingDevice: { id: string; source: string } | null = null; if (device.serialNumber) { existingDevice = await db.device.findFirst({ where: { serialNumber: device.serialNumber, organizationId, }, - select: { id: true }, + select: { id: true, source: true }, }); } + + // Never overwrite a device owned by the agent or Fleet that happens to + // share a hardware serial — doing so would hijack (and later expose to + // deletion) a device managed by a richer source. Leave it untouched. + if (existingDevice && existingDevice.source !== 'integration') { + result.skipped++; + result.details.push({ + identifier, + status: 'skipped', + reason: `Serial already managed by "${existingDevice.source}" — left untouched`, + }); + continue; + } + if (!existingDevice && device.externalId) { existingDevice = await db.device.findFirst({ where: { externalDeviceId: device.externalId, integrationConnectionId: connectionId, + source: 'integration', }, - select: { id: true }, + select: { id: true, source: true }, }); } @@ -176,12 +204,19 @@ export class GenericDeviceSyncService { serialNumber: device.serialNumber, organizationId, }, - select: { id: true }, + select: { id: true, source: true }, }); - if (conflicting) { + if (conflicting && conflicting.source !== 'integration') { + result.skipped++; + result.details.push({ + identifier, + status: 'skipped', + reason: `Serial already managed by "${conflicting.source}" — left untouched`, + }); + } else if (conflicting) { await db.device.update({ where: { id: conflicting.id }, - data: updateData, + data: { ...updateData, memberId: member.id }, }); result.updated++; result.details.push({ identifier, status: 'updated' }); @@ -212,8 +247,18 @@ export class GenericDeviceSyncService { // Phase 2: Remove disappeared devices // ================================================================== - // Only run removal if we actually processed at least one device successfully - if (syncedIdentifiers.length === 0) { + // Phase 2 removal only runs when the provider is the authoritative device + // source (isDirectorySource). Like the employee sync, a non-authoritative + // or partial provider response must NEVER delete devices it simply didn't + // return this run. + // WARNING: the removal below is a HARD delete that cascades to the devices' + // Finding rows. It must be converted to a recoverable soft-removal before + // any provider sets isDirectorySource=true. + if (!isDirectorySource) { + this.logger.log( + `[DeviceSync] Phase 2 skipped for "${providerName}": isDirectorySource=false. Devices absent from the sync payload were left alone.`, + ); + } else if (syncedIdentifiers.length === 0) { this.logger.log( '[DeviceSync] No devices successfully processed — skipping Phase 2 removal', ); diff --git a/apps/api/src/trigger/integration-platform/run-device-sync.spec.ts b/apps/api/src/trigger/integration-platform/run-device-sync.spec.ts new file mode 100644 index 0000000000..47c8be0e45 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/run-device-sync.spec.ts @@ -0,0 +1,21 @@ +// Mock the Trigger.dev SDK at the module boundary so importing the task does +// not require a trigger runtime. `task()` simply returns its config object, +// which lets us assert on the static configuration (e.g. maxDuration). +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + tags: { add: jest.fn() }, + task: (config: unknown) => config, +})); + +import { runDeviceSync } from './run-device-sync'; + +const config = runDeviceSync as unknown as { id: string; maxDuration: number }; + +describe('runDeviceSync task config', () => { + it('declares maxDuration in SECONDS, not milliseconds', () => { + // Trigger.dev maxDuration is in seconds. 10 minutes = 600. + // The ms form (1000 * 60 * 10 = 600_000) would be ~7 days. + expect(config.maxDuration).toBe(600); + expect(config.maxDuration).toBeLessThan(24 * 60 * 60); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/run-device-sync.ts b/apps/api/src/trigger/integration-platform/run-device-sync.ts index 4be301a01d..196a957d4d 100644 --- a/apps/api/src/trigger/integration-platform/run-device-sync.ts +++ b/apps/api/src/trigger/integration-platform/run-device-sync.ts @@ -11,7 +11,7 @@ const API_BASE_URL = process.env.BASE_URL || 'http://localhost:3333'; */ export const runDeviceSync = task({ id: 'run-device-sync', - maxDuration: 1000 * 60 * 10, // 10 minutes + maxDuration: 60 * 10, // 10 minutes — Trigger.dev maxDuration is in SECONDS run: async (payload: { organizationId: string; connectionId: string; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx new file mode 100644 index 0000000000..90fdb5dd19 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DeviceSyncProviderSelector } from './DeviceSyncProviderSelector'; +import type { DeviceSyncProviderInfo } from '../hooks/useDeviceSync'; + +const { mockHasPermission, mockUseDeviceSync } = vi.hoisted(() => ({ + mockHasPermission: vi.fn(), + mockUseDeviceSync: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_1' }), +})); + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ hasPermission: mockHasPermission }), +})); + +vi.mock('../hooks/useDeviceSync', () => ({ + useDeviceSync: () => mockUseDeviceSync(), +})); + +const provider: DeviceSyncProviderInfo = { + slug: 'jamf', + name: 'Jamf', + logoUrl: 'https://example.com/jamf.png', + connected: true, + connectionId: 'icn_1', + lastSyncAt: null, + nextSyncAt: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseDeviceSync.mockReturnValue({ + selectedProvider: 'jamf', + isSyncing: false, + isLoading: false, + availableProviders: [provider], + syncDevices: vi.fn(), + setSyncProvider: vi.fn(), + getProviderName: (slug: string) => (slug === 'jamf' ? 'Jamf' : slug), + getProviderLogo: () => provider.logoUrl, + hasAnyConnection: true, + }); +}); + +describe('DeviceSyncProviderSelector — RBAC gating', () => { + it('renders the sync controls for a user with integration:update', () => { + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'update', + ); + + render(); + + expect( + screen.getByRole('button', { name: /Sync now/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Jamf')).toBeInTheDocument(); + }); + + it('renders nothing for a user without integration:update', () => { + mockHasPermission.mockReturnValue(false); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect( + screen.queryByRole('button', { name: /Sync now/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render for read-only integration access (integration:read only)', () => { + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'read', + ); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); 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 index ba204b74d6..272cb6f507 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx @@ -3,10 +3,12 @@ import { useParams } from 'next/navigation'; import { Button, Skeleton } from '@trycompai/design-system'; import { Renew } from '@trycompai/design-system/icons'; +import { usePermissions } from '@/hooks/use-permissions'; import { useDeviceSync } from '../hooks/useDeviceSync'; export function DeviceSyncProviderSelector() { const { orgId } = useParams<{ orgId: string }>(); + const { hasPermission } = usePermissions(); const { selectedProvider, isSyncing, @@ -19,6 +21,13 @@ export function DeviceSyncProviderSelector() { hasAnyConnection, } = useDeviceSync({ organizationId: orgId }); + // Selecting a provider and triggering syncs mutate org-level device-sync + // settings — both backed by `integration:update` endpoints. Hide the controls + // entirely for users without that permission. + if (!hasPermission('integration', 'update')) { + return null; + } + if (isLoading) { return ; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts index cad18f7bd8..08347c701a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts @@ -121,8 +121,12 @@ export function useDeviceSync({ organizationId }: UseDeviceSyncOptions): UseDevi `/v1/integrations/sync/dynamic/${provider}/devices?organizationId=${organizationId}&connectionId=${connId}`, ); - if (response.data?.success) { - const { imported, updated, removed, skipped, errors } = response.data; + // Branch on the presence of a result body, NOT on `success`: a partial + // sync returns HTTP 200 with success=false (errors > 0) but still imports + // some devices — we must surface both the summary and the warning. + if (response.data) { + const { totalFound, imported, updated, removed, skipped, errors } = + response.data; const parts: string[] = []; if (imported > 0) parts.push(`${imported} new`); if (updated > 0) parts.push(`${updated} updated`); @@ -130,8 +134,8 @@ export function useDeviceSync({ organizationId }: UseDeviceSyncOptions): UseDevi if (skipped > 0) parts.push(`${skipped} skipped`); if (parts.length > 0) { - toast.success(`Synced ${response.data.totalFound} devices — ${parts.join(', ')}`); - } else { + toast.success(`Synced ${totalFound} devices — ${parts.join(', ')}`); + } else if (errors === 0) { toast.info('All devices are already synced'); } diff --git a/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts b/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts index 7b17d2ba8f..aed2a4fb34 100644 --- a/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts +++ b/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach } from 'bun:test'; -import { interpretDeclarativeCheck } from '../interpreter'; +import { + interpretDeclarativeCheck, + interpretDeclarativeDeviceSync, +} from '../interpreter'; import type { CheckContext } from '../../types'; -import type { CheckDefinition } from '../types'; +import type { CheckDefinition, SyncDefinition } from '../types'; /** * Creates a mock CheckContext for testing. @@ -1083,3 +1086,72 @@ describe('interpretDeclarativeCheck', () => { }); }); }); + +describe('interpretDeclarativeDeviceSync', () => { + const deviceDef = (code: string, devicesPath = 'devices'): SyncDefinition => ({ + steps: [{ type: 'code', code }], + employeesPath: 'employees', + devicesPath, + isDirectorySource: false, + }); + + it('returns devices validated against the DEVICE schema (regression: not the employee schema)', async () => { + // A device object has userEmail + platform but no `email`; the employee + // interpreter would drop/strip it. The device interpreter keeps it intact. + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(`scope.devices = [ + { name: 'MB Pro', platform: 'macos', serialNumber: 'SN1', userEmail: 'alice@x.com', status: 'active' }, + ];`), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + platform: 'macos', + userEmail: 'alice@x.com', + serialNumber: 'SN1', + }); + }); + + it('drops entries that fail SyncDeviceSchema and keeps the valid ones', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(`scope.devices = [ + { name: 'Good', platform: 'macos', userEmail: 'a@x.com', status: 'active' }, + { name: 'Missing platform + email', status: 'active' }, + { name: 'Bad platform', platform: 'ios', userEmail: 'b@x.com', status: 'active' }, + ];`), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]!.name).toBe('Good'); + }); + + it('reads from a custom devicesPath', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef( + `scope.fleet = [ + { name: 'PC', platform: 'windows', userEmail: 'c@x.com', status: 'active' }, + ];`, + 'fleet', + ), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]!.platform).toBe('windows'); + }); + + it('throws when the devices path does not resolve to an array', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(`scope.devices = { not: 'an array' };`), + }); + + await expect(runner.run(createMockContext())).rejects.toThrow( + 'did not produce an array', + ); + }); +}); diff --git a/packages/integration-platform/src/dsl/index.ts b/packages/integration-platform/src/dsl/index.ts index 97350f3c21..13febcd40a 100644 --- a/packages/integration-platform/src/dsl/index.ts +++ b/packages/integration-platform/src/dsl/index.ts @@ -1,5 +1,9 @@ // DSL Engine — Declarative check and sync definitions -export { interpretDeclarativeCheck, interpretDeclarativeSync } from './interpreter'; +export { + interpretDeclarativeCheck, + interpretDeclarativeSync, + interpretDeclarativeDeviceSync, +} from './interpreter'; export { evaluateCondition, evaluateOperator, resolvePath } from './expression-evaluator'; export { interpolate, interpolateTemplate } from './template-engine'; export { validateIntegrationDefinition, type ValidationResult } from './validate'; diff --git a/packages/integration-platform/src/dsl/interpreter.ts b/packages/integration-platform/src/dsl/interpreter.ts index e442aad223..5c0560ab54 100644 --- a/packages/integration-platform/src/dsl/interpreter.ts +++ b/packages/integration-platform/src/dsl/interpreter.ts @@ -4,6 +4,7 @@ import type { CheckDefinition, SyncDefinition, SyncEmployee, + SyncDevice, FetchStep, FetchPagesStep, ForEachStep, @@ -12,7 +13,7 @@ import type { EmitStep, CodeStep, } from './types'; -import { SyncEmployeeSchema } from './types'; +import { SyncEmployeeSchema, SyncDeviceSchema } from './types'; import { evaluateCondition, evaluateOperator, resolvePath } from './expression-evaluator'; import { interpolate, interpolateTemplate } from './template-engine'; @@ -115,6 +116,65 @@ export function interpretDeclarativeSync(opts: { }; } +/** + * Converts a declarative SyncDefinition (JSON DSL) into a function that + * produces a validated list of SyncDevice objects. + * + * Mirrors interpretDeclarativeSync but resolves the device list at + * `scope[devicesPath]` (default `devices`) and validates each item with + * SyncDeviceSchema. Kept separate from the employee interpreter so the two + * validation paths never bleed into each other. + */ +export function interpretDeclarativeDeviceSync(opts: { + definition: SyncDefinition; + defaultSeverity?: FindingSeverity; +}): { + run: (ctx: CheckContext) => Promise; +} { + return { + run: async (ctx: CheckContext) => { + const scope: Record = { + variables: ctx.variables, + credentials: ctx.credentials, + accessToken: ctx.accessToken, + connectionId: ctx.connectionId, + organizationId: ctx.organizationId, + metadata: ctx.metadata, + }; + + ctx.log('Running declarative device sync'); + + for (const step of opts.definition.steps) { + await executeStep(step, scope, ctx, opts.defaultSeverity || 'medium'); + } + + const devicesPath = opts.definition.devicesPath || 'devices'; + const raw = resolvePath(scope, devicesPath); + + if (!Array.isArray(raw)) { + throw new Error( + `Device sync definition did not produce an array at scope.${devicesPath}`, + ); + } + + const devices: SyncDevice[] = []; + for (let i = 0; i < raw.length; i++) { + const parsed = SyncDeviceSchema.safeParse(raw[i]); + if (!parsed.success) { + ctx.warn( + `Device at index ${i} failed validation: ${parsed.error.issues.map((iss) => iss.message).join(', ')}`, + ); + continue; + } + devices.push(parsed.data); + } + + ctx.log(`Device sync produced ${devices.length} validated devices`); + return devices; + }, + }; +} + /** * Execute a single DSL step. */ diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 9729c6423e..b3d6a52168 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -290,6 +290,12 @@ export type SyncEmployee = z.infer; export const SyncDefinitionSchema = z.object({ steps: z.array(DSLStepSchema), employeesPath: z.string().default('employees'), + /** + * For device sync definitions: the scope path that the DSL steps populate + * with the standardized device list. Defaults to `devices`. Ignored by the + * employee interpreter (which uses `employeesPath`). + */ + devicesPath: z.string().optional().default('devices'), variables: z.array(VariableSchema).optional(), /** * Whether this provider is authoritative for "who works here" (directory of record). @@ -354,9 +360,12 @@ export const DynamicIntegrationDefinitionSchema = z.object({ type: z.enum(['oauth2', 'api_key', 'basic', 'jwt', 'custom']), config: z.record(z.string(), z.unknown()), }), - capabilities: z.array(z.enum(['checks', 'webhook', 'sync'])).default(['checks']), + capabilities: z + .array(z.enum(['checks', 'webhook', 'sync', 'device_sync'])) + .default(['checks']), supportsMultipleConnections: z.boolean().optional(), syncDefinition: SyncDefinitionSchema.optional(), + deviceSyncDefinition: SyncDefinitionSchema.optional(), services: z.array( z.object({ id: z.string(), diff --git a/packages/integration-platform/src/index.ts b/packages/integration-platform/src/index.ts index 004f9801c8..6131578d9c 100644 --- a/packages/integration-platform/src/index.ts +++ b/packages/integration-platform/src/index.ts @@ -99,6 +99,7 @@ export { export { interpretDeclarativeCheck, interpretDeclarativeSync, + interpretDeclarativeDeviceSync, evaluateCondition, evaluateOperator, resolvePath, From 75599066f01fcd68e8c0e6e2fda3609db78a50d8 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 3 Jun 2026 14:56:18 -0400 Subject: [PATCH 12/14] fix(integration-platform): address device-sync review round 2 Fixes the second cubic review on PR #2802: - schedule: report success based on whether every queued task batch was dispatched, instead of always returning success: true (was masking batchTrigger failures; this schedule now also dispatches device syncs). - device sync endpoint: verify the connectionId actually belongs to providerSlug, so a connection can't be driven through the wrong provider manifest/sync logic. - set-device-sync-provider: normalize blank/whitespace provider to null so an empty string is never persisted and later picked up by the scheduler. - Device: add @@unique([integrationConnectionId, externalDeviceId]) (+ migration) to prevent duplicate integration devices and back the fallback lookup; the P2002 fallback now resolves the conflict by serial OR externalDeviceId (and no longer queries with an undefined identifier). - DeviceSyncProviderSelector: show the provider picker when the saved provider is no longer connected (was leaving the UI stuck); gate the useDeviceSync hook on the integration:update permission so users without it make no API calls. - useDeviceSync: abort the sync when persisting the provider choice fails, instead of syncing with a stale/unsaved provider. Tests: externalId P2002 fallback (service), picker visibility + hook-disabled gating (component), abort-on-set-failure + no-fetch-when-disabled (hook). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/sync.controller.ts | 18 +++- .../generic-device-sync.service.spec.ts | 34 +++++++ .../services/generic-device-sync.service.ts | 38 ++++++-- .../run-integration-checks-schedule.ts | 4 +- .../DeviceSyncProviderSelector.test.tsx | 37 ++++++- .../components/DeviceSyncProviderSelector.tsx | 14 +-- .../devices/hooks/useDeviceSync.test.tsx | 96 +++++++++++++++++++ .../people/devices/hooks/useDeviceSync.ts | 32 +++++-- .../migration.sql | 6 ++ packages/db/prisma/schema/device.prisma | 4 + 10 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.test.tsx create mode 100644 packages/db/prisma/migrations/20260603120000_add_device_external_id_unique/migration.sql diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 315084d49e..abfefa3eea 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1703,7 +1703,9 @@ export class SyncController { @OrganizationId() organizationId: string, @Body() body: { provider: string | null }, ) { - const { provider } = body; + // Normalize blank/whitespace to null so an empty string is never persisted + // as a configured provider (the scheduler would otherwise sync a blank slug). + const provider = body.provider?.trim() ? body.provider.trim() : null; if (provider) { const allManifests = registry.getActiveManifests(); @@ -2060,6 +2062,20 @@ export class SyncController { throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); } + // Verify the connection actually belongs to the requested provider, so a + // connectionId for one provider can't be driven through another's manifest + // and sync logic. + const expectedProvider = await db.integrationProvider.findUnique({ + where: { slug: providerSlug }, + select: { id: true }, + }); + if (!expectedProvider || connection.providerId !== expectedProvider.id) { + throw new HttpException( + `Connection does not belong to provider "${providerSlug}"`, + HttpStatus.BAD_REQUEST, + ); + } + // 2. Get manifest from registry — must have 'device_sync' capability const manifest = getManifest(providerSlug); if (!manifest) { 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 index e02e2ec589..60f60ba03b 100644 --- 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 @@ -201,6 +201,40 @@ describe('GenericDeviceSyncService', () => { expect(result.updated).toBe(1); }); + it('falls back via externalDeviceId when create hits the externalId unique constraint', async () => { + // externalId-only device (no serial): a concurrent create hits P2002 on + // (integrationConnectionId, externalDeviceId); the fallback must re-find by + // that key (not by an undefined serialNumber) and update. + mockDeviceFindFirst + .mockResolvedValueOnce(null) // Phase 1 externalId lookup → none yet + .mockResolvedValueOnce({ id: 'dev_ext', source: 'integration' }); // fallback by externalId + mockMemberFindFirst.mockResolvedValue({ id: 'mem_1' }); + mockDeviceCreate.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + }), + ); + + const result = await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ serialNumber: undefined, externalId: 'ext-123' })], + }); + + expect(mockDeviceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev_ext' }, + data: expect.objectContaining({ + memberId: 'mem_1', + externalDeviceId: 'ext-123', + }), + }), + ); + expect(result.updated).toBe(1); + expect(result.errors).toBe(0); + }); + it('skips a device whose serial is already managed by the agent (no hijack)', async () => { mockDeviceFindFirst.mockResolvedValue({ id: 'dev_agent', 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 index 8b3af56b54..16160e4fe3 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -199,13 +199,30 @@ export class GenericDeviceSyncService { 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, source: true }, - }); + // The conflict can be on (serialNumber, organizationId) OR on + // (integrationConnectionId, externalDeviceId). Re-find by whichever + // identifier is present — never query with an undefined value + // (that would drop the filter and match an arbitrary row). + let conflicting: { id: string; source: string } | null = null; + if (device.serialNumber) { + conflicting = await db.device.findFirst({ + where: { + serialNumber: device.serialNumber, + organizationId, + }, + select: { id: true, source: true }, + }); + } + if (!conflicting && device.externalId) { + conflicting = await db.device.findFirst({ + where: { + externalDeviceId: device.externalId, + integrationConnectionId: connectionId, + }, + select: { id: true, source: true }, + }); + } + if (conflicting && conflicting.source !== 'integration') { result.skipped++; result.details.push({ @@ -220,6 +237,13 @@ export class GenericDeviceSyncService { }); result.updated++; result.details.push({ identifier, status: 'updated' }); + } else { + result.errors++; + result.details.push({ + identifier, + status: 'error', + reason: 'Unique conflict but conflicting device not found', + }); } } else { throw createError; 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 3b06af4cfe..a6df362039 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 @@ -216,7 +216,9 @@ export const integrationChecksSchedule = schedules.task({ logger.info(`Triggered ${deviceSyncsTriggered} device syncs`); return { - success: true, + // Report failure when not every queued task batch was dispatched, so a + // partial/failed batchTrigger is not masked as a successful run. + success: totalTriggered === tasksToRun.length, tasksTriggered: totalTriggered, deviceSyncsTriggered, }; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx index 90fdb5dd19..4366fe3d35 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.test.tsx @@ -17,7 +17,8 @@ vi.mock('@/hooks/use-permissions', () => ({ })); vi.mock('../hooks/useDeviceSync', () => ({ - useDeviceSync: () => mockUseDeviceSync(), + useDeviceSync: (opts: { organizationId: string; enabled?: boolean }) => + mockUseDeviceSync(opts), })); const provider: DeviceSyncProviderInfo = { @@ -58,9 +59,13 @@ describe('DeviceSyncProviderSelector — RBAC gating', () => { screen.getByRole('button', { name: /Sync now/i }), ).toBeInTheDocument(); expect(screen.getByText('Jamf')).toBeInTheDocument(); + // Hook is enabled (and therefore allowed to hit the device-sync APIs). + expect(mockUseDeviceSync).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); }); - it('renders nothing for a user without integration:update', () => { + it('renders nothing for a user without integration:update and disables the hook', () => { mockHasPermission.mockReturnValue(false); const { container } = render(); @@ -69,6 +74,34 @@ describe('DeviceSyncProviderSelector — RBAC gating', () => { expect( screen.queryByRole('button', { name: /Sync now/i }), ).not.toBeInTheDocument(); + // The hook must be disabled so no device-sync API is called without permission. + expect(mockUseDeviceSync).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('shows the provider picker when the saved provider is no longer connected', () => { + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'update', + ); + mockUseDeviceSync.mockReturnValue({ + selectedProvider: 'jamf', // saved, but no longer in the connected list + isSyncing: false, + isLoading: false, + availableProviders: [{ ...provider, slug: 'kandji', name: 'Kandji' }], + syncDevices: vi.fn(), + setSyncProvider: vi.fn(), + getProviderName: (slug: string) => (slug === 'kandji' ? 'Kandji' : slug), + getProviderLogo: () => provider.logoUrl, + hasAnyConnection: true, + }); + + render(); + + // The picker must be available so the user can switch to a connected provider. + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Kandji' })).toBeInTheDocument(); }); it('does not render for read-only integration access (integration:read only)', () => { 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 index 272cb6f507..1d899a7019 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx @@ -9,6 +9,10 @@ import { useDeviceSync } from '../hooks/useDeviceSync'; export function DeviceSyncProviderSelector() { const { orgId } = useParams<{ orgId: string }>(); const { hasPermission } = usePermissions(); + // Selecting a provider and triggering syncs are integration:update actions. + // Gate the hook itself so users without the permission never hit any + // device-sync API, and hide the controls below. + const canManageDeviceSync = hasPermission('integration', 'update'); const { selectedProvider, isSyncing, @@ -19,12 +23,9 @@ export function DeviceSyncProviderSelector() { getProviderName, getProviderLogo, hasAnyConnection, - } = useDeviceSync({ organizationId: orgId }); + } = useDeviceSync({ organizationId: orgId, enabled: canManageDeviceSync }); - // Selecting a provider and triggering syncs mutate org-level device-sync - // settings — both backed by `integration:update` endpoints. Hide the controls - // entirely for users without that permission. - if (!hasPermission('integration', 'update')) { + if (!canManageDeviceSync) { return null; } @@ -89,7 +90,8 @@ export function DeviceSyncProviderSelector() {
- {connectedProviders.length > 1 || !selectedProvider ? ( + {connectedProviders.length > 1 || + !connectedProviders.some((p) => p.slug === selectedProvider) ? (