From b9e7188e2ddad66f780237e52020b994171a126d Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 11:05:50 +0100 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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) {