diff --git a/packages/core/integrations/repositories/integration-repository-documentdb.js b/packages/core/integrations/repositories/integration-repository-documentdb.js index ae813e873..ab1b13d2f 100644 --- a/packages/core/integrations/repositories/integration-repository-documentdb.js +++ b/packages/core/integrations/repositories/integration-repository-documentdb.js @@ -148,6 +148,30 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { return doc ? this._mapIntegration(doc) : null; } + async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) { + const userObjectId = toObjectId(userId); + if (!userObjectId) return null; + + const targetIds = toObjectIdArray(entityIds || []) + .map((value) => fromObjectId(value)) + .sort(); + + const candidates = await findMany(this.prisma, 'Integration', { + userId: userObjectId, + 'config.type': type, + }); + + const match = candidates.find((doc) => { + const current = (doc?.entityIds || []) + .map((value) => fromObjectId(value)) + .sort(); + if (current.length !== targetIds.length) return false; + return current.every((id, idx) => id === targetIds[idx]); + }); + + return match ? this._mapIntegration(match) : null; + } + async updateIntegrationConfig(integrationId, config) { if (config === null || config === undefined) { throw new Error('Config parameter is required'); diff --git a/packages/core/integrations/repositories/integration-repository-interface.js b/packages/core/integrations/repositories/integration-repository-interface.js index 260f5df67..03326ef76 100644 --- a/packages/core/integrations/repositories/integration-repository-interface.js +++ b/packages/core/integrations/repositories/integration-repository-interface.js @@ -122,6 +122,25 @@ class IntegrationRepositoryInterface { async updateIntegrationConfig(integrationId, config) { throw new Error('Method updateIntegrationConfig must be implemented by subclass'); } + + /** + * Find an integration for a user whose config.type matches and whose entity + * set is exactly equal to the provided entityIds (order-insensitive). + * + * Used to dedupe integration creation when the same user re-authorizes the + * same integration type against the same external account — entity identity + * already encodes the external account, since entities are deduped by + * (userId, moduleName, externalId) during the authorization callback. + * + * @param {string|number} userId - User ID + * @param {string} type - Integration type (config.type) + * @param {Array} entityIds - Entity IDs that must match the integration's entity set exactly + * @returns {Promise} Existing integration or null + * @abstract + */ + async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) { + throw new Error('Method findIntegrationByUserIdTypeAndEntities must be implemented by subclass'); + } } module.exports = { IntegrationRepositoryInterface }; diff --git a/packages/core/integrations/repositories/integration-repository-mongo.js b/packages/core/integrations/repositories/integration-repository-mongo.js index 75ae091ec..c8c60c02f 100644 --- a/packages/core/integrations/repositories/integration-repository-mongo.js +++ b/packages/core/integrations/repositories/integration-repository-mongo.js @@ -298,6 +298,54 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { messages: integration.messages, }; } + + /** + * Find an existing integration for this user whose config.type matches + * and whose entity set is exactly equal to entityIds (order-insensitive). + * + * @param {string} userId - User ID (MongoDB ObjectId as string) + * @param {string} type - Integration type (config.type) + * @param {Array} entityIds - Entity IDs to match exactly + * @returns {Promise} Existing integration or null + */ + async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) { + const targetIds = [...(entityIds || [])].map(String).sort(); + + const candidates = await this.prisma.integration.findMany({ + where: { + userId, + config: { + path: ['type'], + equals: type, + }, + }, + include: { + entities: true, + }, + }); + + const match = candidates.find((integration) => { + const current = (integration.entities || []) + .map((e) => String(e.id)) + .sort(); + if (current.length !== targetIds.length) return false; + return current.every((id, idx) => id === targetIds[idx]); + }); + + if (!match) { + return null; + } + + return { + id: match.id, + entitiesIds: match.entities.map((e) => e.id), + userId: match.userId, + config: match.config, + version: match.version, + status: match.status, + messages: match.messages, + }; + } } module.exports = { IntegrationRepositoryMongo }; diff --git a/packages/core/integrations/repositories/integration-repository-postgres.js b/packages/core/integrations/repositories/integration-repository-postgres.js index c63042ee0..f114e85f0 100644 --- a/packages/core/integrations/repositories/integration-repository-postgres.js +++ b/packages/core/integrations/repositories/integration-repository-postgres.js @@ -347,6 +347,58 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { messages: converted.messages, }; } + + /** + * Find an existing integration for this user whose config.type matches + * and whose entity set is exactly equal to entityIds (order-insensitive). + * + * @param {string} userId - User ID (string from application layer) + * @param {string} type - Integration type (config.type) + * @param {Array} entityIds - Entity IDs to match exactly + * @returns {Promise} Existing integration or null + */ + async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) { + const intUserId = this._convertId(userId); + const targetIds = [...(entityIds || [])] + .map((id) => this._convertId(id)) + .sort((a, b) => a - b); + + const candidates = await this.prisma.integration.findMany({ + where: { + userId: intUserId, + config: { + path: ['type'], + equals: type, + }, + }, + include: { + entities: true, + }, + }); + + const match = candidates.find((integration) => { + const current = (integration.entities || []) + .map((e) => e.id) + .sort((a, b) => a - b); + if (current.length !== targetIds.length) return false; + return current.every((id, idx) => id === targetIds[idx]); + }); + + if (!match) { + return null; + } + + const converted = this._convertIntegrationIds(match); + return { + id: converted.id, + entitiesIds: converted.entities.map((e) => e.id), + userId: converted.userId, + config: converted.config, + version: converted.version, + status: converted.status, + messages: converted.messages, + }; + } } module.exports = { IntegrationRepositoryPostgres }; diff --git a/packages/core/integrations/tests/doubles/dummy-integration-class.js b/packages/core/integrations/tests/doubles/dummy-integration-class.js index c860c7744..1e70d7a26 100644 --- a/packages/core/integrations/tests/doubles/dummy-integration-class.js +++ b/packages/core/integrations/tests/doubles/dummy-integration-class.js @@ -32,6 +32,7 @@ class DummyIntegration extends IntegrationBase { constructor(params) { super(params); this.sendSpy = jest.fn(); + this.testAuthSpy = jest.fn(); this.eventCallHistory = []; this.events = {}; @@ -66,6 +67,10 @@ class DummyIntegration extends IntegrationBase { return; } + async testAuth() { + this.testAuthSpy(); + } + async onCreate({ integrationId }) { return; } diff --git a/packages/core/integrations/tests/doubles/test-integration-repository.js b/packages/core/integrations/tests/doubles/test-integration-repository.js index d6335815a..f8f6bd22e 100644 --- a/packages/core/integrations/tests/doubles/test-integration-repository.js +++ b/packages/core/integrations/tests/doubles/test-integration-repository.js @@ -46,6 +46,25 @@ class TestIntegrationRepository { return record || null; } + async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) { + const targetIds = [...(entityIds || [])].map(String).sort(); + const record = Array.from(this.store.values()).find((r) => { + if (r.userId !== userId) return false; + if (r.config?.type !== type) return false; + const current = [...(r.entitiesIds || [])].map(String).sort(); + if (current.length !== targetIds.length) return false; + return current.every((id, idx) => id === targetIds[idx]); + }); + this.operationHistory.push({ + operation: 'findByUserIdTypeAndEntities', + userId, + type, + entityIds: targetIds, + found: !!record, + }); + return record || null; + } + async updateIntegrationMessages(id, type, title, body, timestamp) { const rec = this.store.get(id); if (!rec) { diff --git a/packages/core/integrations/tests/use-cases/create-integration.test.js b/packages/core/integrations/tests/use-cases/create-integration.test.js index d4013f0c6..ca2c50b84 100644 --- a/packages/core/integrations/tests/use-cases/create-integration.test.js +++ b/packages/core/integrations/tests/use-cases/create-integration.test.js @@ -128,4 +128,102 @@ describe('CreateIntegration Use-Case', () => { expect(dto.config).toEqual(config); }); }); + + describe('deduplication on re-authorize', () => { + it('reuses an existing integration when userId, type, and entity set all match', async () => { + const entities = ['entity-1', 'entity-2']; + const userId = 'user-1'; + const config = { type: 'dummy', foo: 'bar' }; + + const first = await useCase.execute(entities, userId, config); + integrationRepository.clearHistory(); + + const second = await useCase.execute(entities, userId, config); + + expect(second.id).toBe(first.id); + + const history = integrationRepository.getOperationHistory(); + expect(history.some((op) => op.operation === 'create')).toBe(false); + expect( + history.find((op) => op.operation === 'findByUserIdTypeAndEntities'), + ).toMatchObject({ userId, type: 'dummy', found: true }); + }); + + it('runs testAuth on the reused integration so stale credentials surface', async () => { + const entities = ['entity-1']; + const userId = 'user-1'; + const config = { type: 'dummy' }; + + const testAuthSpy = jest.fn(); + const integrationClass = class extends DummyIntegration { + async testAuth() { + testAuthSpy(); + } + }; + Object.defineProperty(integrationClass, 'Definition', { + value: DummyIntegration.Definition, + }); + + const localUseCase = new CreateIntegration({ + integrationRepository, + integrationClasses: [integrationClass], + moduleFactory, + }); + + await localUseCase.execute(entities, userId, config); + await localUseCase.execute(entities, userId, config); + + expect(testAuthSpy).toHaveBeenCalledTimes(1); + }); + + it('ignores entity order when checking for duplicates', async () => { + const userId = 'user-1'; + const config = { type: 'dummy' }; + + const first = await useCase.execute(['entity-1', 'entity-2'], userId, config); + const second = await useCase.execute(['entity-2', 'entity-1'], userId, config); + + expect(second.id).toBe(first.id); + }); + + it('creates a new integration when the type differs', async () => { + const entities = ['entity-1']; + const userId = 'user-1'; + + const dummyTwoClass = class extends DummyIntegration {}; + Object.defineProperty(dummyTwoClass, 'Definition', { + value: { ...DummyIntegration.Definition, name: 'dummy-two' }, + }); + const localUseCase = new CreateIntegration({ + integrationRepository, + integrationClasses: [DummyIntegration, dummyTwoClass], + moduleFactory, + }); + + const first = await localUseCase.execute(entities, userId, { type: 'dummy' }); + const second = await localUseCase.execute(entities, userId, { type: 'dummy-two' }); + + expect(second.id).not.toBe(first.id); + }); + + it('creates a new integration when the entity set differs', async () => { + const userId = 'user-1'; + const config = { type: 'dummy' }; + + const first = await useCase.execute(['entity-1'], userId, config); + const second = await useCase.execute(['entity-1', 'entity-2'], userId, config); + + expect(second.id).not.toBe(first.id); + }); + + it('creates a new integration when the userId differs', async () => { + const entities = ['entity-1']; + const config = { type: 'dummy' }; + + const first = await useCase.execute(entities, 'user-1', config); + const second = await useCase.execute(entities, 'user-2', config); + + expect(second.id).not.toBe(first.id); + }); + }); }); \ No newline at end of file diff --git a/packages/core/integrations/use-cases/create-integration.js b/packages/core/integrations/use-cases/create-integration.js index 54ae66c2d..4d653f81b 100644 --- a/packages/core/integrations/use-cases/create-integration.js +++ b/packages/core/integrations/use-cases/create-integration.js @@ -23,15 +23,37 @@ class CreateIntegration { /** * Executes the integration creation process. + * + * If an integration for this user with the same type and identical entity + * set already exists, the existing record is reused instead of being + * duplicated. In that case the integration is loaded, `testAuth` is run so + * that stale credentials surface as an `ERROR` status / message, and the + * existing DTO is returned. Entity identity already encodes the external + * account because `ProcessAuthorizationCallback` dedupes entities by + * (userId, moduleName, externalId). + * * @async * @param {string[]} entities - Array of entity IDs to associate with the integration. * @param {string} userId - ID of the user creating the integration. * @param {Object} config - Configuration object for the integration. * @param {string} config.type - Type of integration to create. - * @returns {Promise} The created integration DTO. + * @returns {Promise} The created or reused integration DTO. * @throws {Error} When integration class is not found for the specified type. */ async execute(entities, userId, config) { + const existing = + await this.integrationRepository.findIntegrationByUserIdTypeAndEntities( + userId, + config?.type, + entities + ); + + if (existing) { + const instance = await this._buildInstance(existing); + await instance.testAuth(); + return mapIntegrationClassToIntegrationDTO(instance); + } + const integrationRecord = await this.integrationRepository.createIntegration( entities, @@ -39,6 +61,20 @@ class CreateIntegration { config ); + const integrationInstance = await this._buildInstance(integrationRecord); + + await integrationInstance.send('ON_CREATE', { + integrationId: integrationRecord.id, + }); + + return mapIntegrationClassToIntegrationDTO(integrationInstance); + } + + /** + * Build and initialize an integration instance from a persisted record. + * @private + */ + async _buildInstance(integrationRecord) { const integrationClass = this.integrationClasses.find( (integrationClass) => integrationClass.Definition.name === @@ -72,11 +108,7 @@ class CreateIntegration { }); await integrationInstance.initialize(); - await integrationInstance.send('ON_CREATE', { - integrationId: integrationRecord.id, - }); - - return mapIntegrationClassToIntegrationDTO(integrationInstance); + return integrationInstance; } }