Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,30 @@
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();

Check failure on line 157 in packages/core/integrations/repositories/integration-repository-documentdb.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function to avoid sorting elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZ2Ii-cPD4ljEi2GQMK0&open=AZ2Ii-cPD4ljEi2GQMK0&pullRequest=572

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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|number>} entityIds - Entity IDs that must match the integration's entity set exactly
* @returns {Promise<Object|null>} Existing integration or null
* @abstract
*/
async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) {
throw new Error('Method findIntegrationByUserIdTypeAndEntities must be implemented by subclass');
}
}

module.exports = { IntegrationRepositoryInterface };
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,54 @@
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<string>} entityIds - Entity IDs to match exactly
* @returns {Promise<Object|null>} Existing integration or null
*/
async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) {
const targetIds = [...(entityIds || [])].map(String).sort();

Check failure on line 312 in packages/core/integrations/repositories/integration-repository-mongo.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZ2Ii-S6D4ljEi2GQMKz&open=AZ2Ii-S6D4ljEi2GQMKz&pullRequest=572

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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} entityIds - Entity IDs to match exactly
* @returns {Promise<Object|null>} 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 };
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class DummyIntegration extends IntegrationBase {
constructor(params) {
super(params);
this.sendSpy = jest.fn();
this.testAuthSpy = jest.fn();
this.eventCallHistory = [];
this.events = {};

Expand Down Expand Up @@ -66,6 +67,10 @@ class DummyIntegration extends IntegrationBase {
return;
}

async testAuth() {
this.testAuthSpy();
}

async onCreate({ integrationId }) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@
return record || null;
}

async findIntegrationByUserIdTypeAndEntities(userId, type, entityIds) {
const targetIds = [...(entityIds || [])].map(String).sort();

Check failure on line 50 in packages/core/integrations/tests/doubles/test-integration-repository.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZ2Ii-cfD4ljEi2GQMK1&open=AZ2Ii-cfD4ljEi2GQMK1&pullRequest=572
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();

Check failure on line 54 in packages/core/integrations/tests/doubles/test-integration-repository.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZ2Ii-cfD4ljEi2GQMK2&open=AZ2Ii-cfD4ljEi2GQMK2&pullRequest=572
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
44 changes: 38 additions & 6 deletions packages/core/integrations/use-cases/create-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,58 @@ 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<Object>} The created integration DTO.
* @returns {Promise<Object>} 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);
Comment on lines +53 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return refreshed state after re-auth testAuth

When an existing integration is reused, instance.testAuth() persists status/message changes through repository use-cases, but those writes do not mutate the in-memory instance object; immediately mapping instance to DTO can therefore return stale status/messages even when auth just failed. This affects re-authorize requests that depend on the response payload to reflect credential validity, so the flow should reload the integration (or update the instance fields) before returning.

Useful? React with 👍 / 👎.

}

const integrationRecord =
await this.integrationRepository.createIntegration(
entities,
userId,
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 ===
Expand Down Expand Up @@ -72,11 +108,7 @@ class CreateIntegration {
});

await integrationInstance.initialize();
await integrationInstance.send('ON_CREATE', {
integrationId: integrationRecord.id,
});

return mapIntegrationClassToIntegrationDTO(integrationInstance);
return integrationInstance;
}
}

Expand Down
Loading