feat(connections): add an onDisconnect lifecycle hook to connection providers (#23538)
Platform half of the follow-up to https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334. The Slack app claims a `team_id` on connect and had no way to release it, because connection providers only had an on-connect hook. Nothing here is Slack-specific, so it targets `main`. The app side is #23540, on top of `feat/slack-bot`, and waits on this plus an SDK release. ## What changes `defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside `onConnectLogicFunction`. It is stored on `connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast instance command `2.26.0_...1785350000000`) and enqueued right after the `ConnectedAccount` row is deleted, in the disconnecting workspace, with the same payload as on-connect: ```ts type OnDisconnectPayload = { connectionProviderId: string; connectionProviderName: string; connectedAccountId: string; }; ``` The `ConnectedAccount` is gone by the time the hook runs, so `getConnection` no longer resolves. Anything the cleanup needs has to be in the key-value store, written at connect time and keyed by `connectedAccountId`. The docs section spells that out, along with the fact that uninstalling an app drops its connections through a cascade that never reaches this hook, where `uninstallLogicFunction` is the right tool instead. Both dispatches moved into a new `ConnectionProviderLifecycleHookService`, so `ConnectionProviderOAuthFlowService` no longer owns hook plumbing and `ConnectedAccountMetadataService.delete` can reuse it. On-connect behaviour is unchanged: best effort, never blocks the caller, failures go to Sentry. ## Tests - `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect cases moved over, plus on-disconnect dispatch, no-hook, and missing-provider cases - `connection-provider-oauth-flow.service.spec.ts`: now asserts delegation to the lifecycle hook service - SDK validation, manifest duplicate-identifier, and manifest to flat converter specs extended Server unit tests and typecheck for shared, sdk and server pass locally.
This commit is contained in:
@@ -45,6 +45,9 @@ export default defineConnectionProvider({
|
||||
// Optional: a logic function in this app to run right after a connection is
|
||||
// established. See "Run a logic function on connect".
|
||||
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
|
||||
// Optional: a logic function in this app to run right after a connection is
|
||||
// removed. See "Run a logic function on disconnect".
|
||||
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
|
||||
});
|
||||
```
|
||||
|
||||
@@ -124,6 +127,42 @@ From there use `getConnection(connectedAccountId)` to read the fresh access toke
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Run a logic function on disconnect" description="Clean up when a connection is removed">
|
||||
|
||||
Anything an app claims at connect time has to be released when the connection goes away. A Slack integration that claims a `team_id` on connect, for instance, has to release that claim so another workspace can connect the same Slack team. Set `onDisconnectLogicFunction` to reference a logic function in the same app, and it runs right after the `ConnectedAccount` is deleted.
|
||||
|
||||
```ts src/connection-providers/slack-connection.ts
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: '...',
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
/* ... */
|
||||
},
|
||||
// Runs releaseSlackTeam after every Slack disconnection.
|
||||
onDisconnectLogicFunction: {
|
||||
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Like the on-connect hook it runs **asynchronously in the disconnecting workspace** and never blocks the disconnect. The handler receives the same payload shape:
|
||||
|
||||
```ts
|
||||
type OnDisconnectPayload = {
|
||||
connectionProviderId: string;
|
||||
connectionProviderName: string; // e.g. 'slack'
|
||||
connectedAccountId: string;
|
||||
};
|
||||
```
|
||||
|
||||
The `ConnectedAccount` is already gone when the hook runs, so `getConnection(connectedAccountId)` no longer resolves. Anything the cleanup needs (a `team_id`, an external subscription id) must have been written to the [key-value store](/developers/extend/apps/logic/key-value-store) at connect time, keyed by `connectedAccountId`.
|
||||
|
||||
The hook fires when a connection is removed on its own. Uninstalling the app drops its connections through a database cascade instead, so the hook does not run there. Declare an `uninstallLogicFunction` on `defineApplication` for that path: it runs before the app's metadata is deleted, so it can still call `listConnections` and clean up whatever is left.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
|
||||
|
||||
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
|
||||
|
||||
+31
-32
@@ -197,36 +197,39 @@ describe('manifestValidate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not flag a connection provider referencing a logic function via onConnectLogicFunction as a duplicate', () => {
|
||||
const logicFunctionId = '550e8400-e29b-41d4-a716-446655440040';
|
||||
it.each(['onConnectLogicFunction', 'onDisconnectLogicFunction'] as const)(
|
||||
'should not flag a connection provider referencing a logic function via %s as a duplicate',
|
||||
(lifecycleHookKey) => {
|
||||
const logicFunctionId = '550e8400-e29b-41d4-a716-446655440040';
|
||||
|
||||
const logicFunction = {
|
||||
universalIdentifier: logicFunctionId,
|
||||
name: 'onConnect',
|
||||
sourceHandlerPath: 'src/logic-functions/on-connect.ts',
|
||||
builtHandlerPath: 'dist/on-connect.js',
|
||||
builtHandlerChecksum: '00000000-0000-4000-8000-000000000000',
|
||||
handlerName: 'handler',
|
||||
} as unknown as Manifest['logicFunctions'][number];
|
||||
const logicFunction = {
|
||||
universalIdentifier: logicFunctionId,
|
||||
name: lifecycleHookKey,
|
||||
sourceHandlerPath: 'src/logic-functions/lifecycle-hook.ts',
|
||||
builtHandlerPath: 'dist/lifecycle-hook.js',
|
||||
builtHandlerChecksum: '00000000-0000-4000-8000-000000000000',
|
||||
handlerName: 'handler',
|
||||
} as unknown as Manifest['logicFunctions'][number];
|
||||
|
||||
const connectionProvider = {
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {},
|
||||
onConnectLogicFunction: { universalIdentifier: logicFunctionId },
|
||||
} as unknown as NonNullable<Manifest['connectionProviders']>[number];
|
||||
const connectionProvider = {
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {},
|
||||
[lifecycleHookKey]: { universalIdentifier: logicFunctionId },
|
||||
} as unknown as NonNullable<Manifest['connectionProviders']>[number];
|
||||
|
||||
const result = manifestValidate({
|
||||
...validManifest,
|
||||
logicFunctions: [logicFunction],
|
||||
connectionProviders: [connectionProvider],
|
||||
});
|
||||
const result = manifestValidate({
|
||||
...validManifest,
|
||||
logicFunctions: [logicFunction],
|
||||
connectionProviders: [connectionProvider],
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not flag a front component referenced via settingsFrontComponent as a duplicate', () => {
|
||||
const frontComponentId = '550e8400-e29b-41d4-a716-446655440050';
|
||||
@@ -626,18 +629,14 @@ describe('manifestValidate', () => {
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContainEqual(
|
||||
expect.stringContaining(
|
||||
'not "aggregateFieldMetadataId"',
|
||||
),
|
||||
expect.stringContaining('not "aggregateFieldMetadataId"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore non-graph widgets that have no aggregate field', () => {
|
||||
const result = manifestValidate({
|
||||
...validManifest,
|
||||
pageLayoutTabs: [
|
||||
makeGraphWidgetTab({ configurationType: 'TIMELINE' }),
|
||||
],
|
||||
pageLayoutTabs: [makeGraphWidgetTab({ configurationType: 'TIMELINE' })],
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
@@ -75,6 +75,7 @@ const findUniversalIdentifiers = (obj: object): string[] => {
|
||||
key === 'preInstallLogicFunction' ||
|
||||
key === 'uninstallLogicFunction' ||
|
||||
key === 'onConnectLogicFunction' ||
|
||||
key === 'onDisconnectLogicFunction' ||
|
||||
key === 'settingsFrontComponent'
|
||||
) {
|
||||
continue;
|
||||
|
||||
+20
-12
@@ -83,10 +83,15 @@ describe('defineConnectionProvider', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid onConnectLogicFunction', () => {
|
||||
const lifecycleHookKeys = [
|
||||
'onConnectLogicFunction',
|
||||
'onDisconnectLogicFunction',
|
||||
] as const;
|
||||
|
||||
it.each(lifecycleHookKeys)('accepts a valid %s', (lifecycleHookKey) => {
|
||||
const result = defineConnectionProvider({
|
||||
...baseValidConfig,
|
||||
onConnectLogicFunction: {
|
||||
[lifecycleHookKey]: {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
},
|
||||
});
|
||||
@@ -95,15 +100,18 @@ describe('defineConnectionProvider', () => {
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a non-UUID onConnectLogicFunction universalIdentifier', () => {
|
||||
const result = defineConnectionProvider({
|
||||
...baseValidConfig,
|
||||
onConnectLogicFunction: { universalIdentifier: 'claim-team-id' },
|
||||
});
|
||||
it.each(lifecycleHookKeys)(
|
||||
'rejects a non-UUID %s universalIdentifier',
|
||||
(lifecycleHookKey) => {
|
||||
const result = defineConnectionProvider({
|
||||
...baseValidConfig,
|
||||
[lifecycleHookKey]: { universalIdentifier: 'not-a-uuid' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(
|
||||
result.errors.some((error) => error.includes('onConnectLogicFunction')),
|
||||
).toBe(true);
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(
|
||||
result.errors.some((error) => error.includes(lifecycleHookKey)),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+19
-7
@@ -1,6 +1,7 @@
|
||||
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
|
||||
import { type ConnectionProviderManifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
// Matches UUID v1–v5 (and the `00000000-…` Nil UUID). Mirrors the server-side
|
||||
@@ -11,6 +12,16 @@ const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const SUPPORTED_TYPES = ['oauth'] as const;
|
||||
|
||||
type ConnectionProviderLifecycleHookKey = Extract<
|
||||
keyof ConnectionProviderManifest,
|
||||
`on${string}LogicFunction`
|
||||
>;
|
||||
|
||||
const LIFECYCLE_HOOK_KEYS = [
|
||||
'onConnectLogicFunction',
|
||||
'onDisconnectLogicFunction',
|
||||
] as const satisfies readonly ConnectionProviderLifecycleHookKey[];
|
||||
|
||||
export const defineConnectionProvider: DefineEntity<
|
||||
ConnectionProviderManifest
|
||||
> = (config) => {
|
||||
@@ -36,13 +47,14 @@ export const defineConnectionProvider: DefineEntity<
|
||||
errors.push('Connection provider must have a displayName');
|
||||
}
|
||||
|
||||
if (
|
||||
config.onConnectLogicFunction &&
|
||||
!UUID_PATTERN.test(config.onConnectLogicFunction.universalIdentifier)
|
||||
) {
|
||||
errors.push(
|
||||
`Connection provider onConnectLogicFunction.universalIdentifier "${config.onConnectLogicFunction.universalIdentifier}" must be the UUID universalIdentifier of a logic function in this app.`,
|
||||
);
|
||||
for (const hookKey of LIFECYCLE_HOOK_KEYS) {
|
||||
const hook = config[hookKey];
|
||||
|
||||
if (isDefined(hook) && !UUID_PATTERN.test(hook.universalIdentifier)) {
|
||||
errors.push(
|
||||
`Connection provider ${hookKey}.universalIdentifier "${hook.universalIdentifier}" must be the UUID universalIdentifier of a logic function in this app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.type) {
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.27.0', 1785810340935)
|
||||
export class AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."connectionProvider" ADD COLUMN IF NOT EXISTS "onDisconnectLogicFunctionUniversalIdentifier" uuid',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."connectionProvider" DROP COLUMN IF EXISTS "onDisconnectLogicFunctionUniversalIdentifier"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -130,6 +130,7 @@ import { AddAppTokenSsoExchangeIndexFastInstanceCommand } from './2-25/2-25-inst
|
||||
import { AddPageLayoutCascadeDeleteIndexesFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes';
|
||||
import { AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785173910915-add-channel-webhook-subscription-external-id-indexes';
|
||||
import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message';
|
||||
import { AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785810340935-add-on-disconnect-logic-function-to-connection-provider';
|
||||
import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index';
|
||||
import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata';
|
||||
import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table';
|
||||
@@ -266,6 +267,7 @@ export const INSTANCE_COMMANDS = [
|
||||
AddPageLayoutCascadeDeleteIndexesFastInstanceCommand,
|
||||
AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand,
|
||||
AddIsHiddenToAgentMessageFastInstanceCommand,
|
||||
AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand,
|
||||
AddConnectedAccountHandleProviderIndexFastInstanceCommand,
|
||||
AddOpenRecordInToObjectMetadataFastInstanceCommand,
|
||||
CreateUserSessionCoreTableFastInstanceCommand,
|
||||
|
||||
+21
@@ -51,6 +51,7 @@ describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () =
|
||||
usePkce: true,
|
||||
},
|
||||
onConnectLogicFunctionUniversalIdentifier: null,
|
||||
onDisconnectLogicFunctionUniversalIdentifier: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
});
|
||||
@@ -76,6 +77,26 @@ describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () =
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the onDisconnectLogicFunction universalIdentifier into the flat field when provided', () => {
|
||||
const onDisconnectLogicFunctionUniversalIdentifier =
|
||||
'd2d2d2d2-d2d2-4d2d-d2d2-d2d2d2d2d2d2';
|
||||
|
||||
const result =
|
||||
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
|
||||
connectionProviderManifest: buildManifest({
|
||||
onDisconnectLogicFunction: {
|
||||
universalIdentifier: onDisconnectLogicFunctionUniversalIdentifier,
|
||||
},
|
||||
}),
|
||||
applicationUniversalIdentifier: APP_UID,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(result.onDisconnectLogicFunctionUniversalIdentifier).toBe(
|
||||
onDisconnectLogicFunctionUniversalIdentifier,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through optional oauth config when provided', () => {
|
||||
const result =
|
||||
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
|
||||
|
||||
+3
@@ -46,6 +46,9 @@ export const fromConnectionProviderManifestToUniversalFlatConnectionProvider =
|
||||
onConnectLogicFunctionUniversalIdentifier:
|
||||
connectionProviderManifest.onConnectLogicFunction
|
||||
?.universalIdentifier ?? null,
|
||||
onDisconnectLogicFunctionUniversalIdentifier:
|
||||
connectionProviderManifest.onDisconnectLogicFunction
|
||||
?.universalIdentifier ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
|
||||
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const ON_CONNECT_UID = 'c1c1c1c1-c1c1-4c1c-c1c1-c1c1c1c1c1c1';
|
||||
const ON_DISCONNECT_UID = 'd2d2d2d2-d2d2-4d2d-d2d2-d2d2d2d2d2d2';
|
||||
|
||||
describe('ConnectionProviderLifecycleHookService', () => {
|
||||
let service: ConnectionProviderLifecycleHookService;
|
||||
let connectionProviderService: { findOneByIdOrThrow: jest.Mock };
|
||||
let workspaceCacheService: { getOrRecompute: jest.Mock };
|
||||
let messageQueueService: { add: jest.Mock };
|
||||
let exceptionHandlerService: { captureExceptions: jest.Mock };
|
||||
|
||||
const baseProvider: Pick<
|
||||
ConnectionProviderEntity,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'onConnectLogicFunctionUniversalIdentifier'
|
||||
| 'onDisconnectLogicFunctionUniversalIdentifier'
|
||||
> = {
|
||||
id: 'provider-1',
|
||||
name: 'linear',
|
||||
onConnectLogicFunctionUniversalIdentifier: null,
|
||||
onDisconnectLogicFunctionUniversalIdentifier: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
connectionProviderService = {
|
||||
findOneByIdOrThrow: jest.fn(async () => baseProvider),
|
||||
};
|
||||
workspaceCacheService = {
|
||||
getOrRecompute: jest.fn(async () => ({
|
||||
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
|
||||
})),
|
||||
};
|
||||
messageQueueService = { add: jest.fn() };
|
||||
exceptionHandlerService = { captureExceptions: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConnectionProviderLifecycleHookService,
|
||||
{
|
||||
provide: ConnectionProviderService,
|
||||
useValue: connectionProviderService,
|
||||
},
|
||||
{ provide: WorkspaceCacheService, useValue: workspaceCacheService },
|
||||
{
|
||||
provide: getQueueToken(MessageQueue.logicFunctionQueue),
|
||||
useValue: messageQueueService,
|
||||
},
|
||||
{ provide: ExceptionHandlerService, useValue: exceptionHandlerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ConnectionProviderLifecycleHookService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('dispatchOnConnect', () => {
|
||||
it('does not dispatch a hook when the provider declares none', async () => {
|
||||
await service.dispatchOnConnect({
|
||||
provider: baseProvider,
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues the declared on-connect logic function in the connecting workspace', async () => {
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: {
|
||||
byUniversalIdentifier: { [ON_CONNECT_UID]: { id: 'function-1' } },
|
||||
},
|
||||
});
|
||||
|
||||
await service.dispatchOnConnect({
|
||||
provider: {
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
},
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
['flatLogicFunctionMaps'],
|
||||
);
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
'LogicFunctionTriggerJob',
|
||||
{
|
||||
logicFunctionId: 'function-1',
|
||||
workspaceId: 'workspace-1',
|
||||
payload: {
|
||||
connectionProviderId: 'provider-1',
|
||||
connectionProviderName: 'linear',
|
||||
connectedAccountId: 'account-1',
|
||||
},
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports to Sentry without throwing when the hook function is missing', async () => {
|
||||
await service.dispatchOnConnect({
|
||||
provider: {
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
},
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a soft-deleted hook function as missing instead of enqueuing it', async () => {
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[ON_CONNECT_UID]: {
|
||||
id: 'function-1',
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.dispatchOnConnect({
|
||||
provider: {
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
},
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchOnDisconnect', () => {
|
||||
it('does not dispatch a hook when the provider declares none', async () => {
|
||||
await service.dispatchOnDisconnect({
|
||||
connectionProviderId: 'provider-1',
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues the declared on-disconnect logic function in the disconnecting workspace', async () => {
|
||||
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
|
||||
...baseProvider,
|
||||
onDisconnectLogicFunctionUniversalIdentifier: ON_DISCONNECT_UID,
|
||||
});
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: {
|
||||
byUniversalIdentifier: { [ON_DISCONNECT_UID]: { id: 'function-2' } },
|
||||
},
|
||||
});
|
||||
|
||||
await service.dispatchOnDisconnect({
|
||||
connectionProviderId: 'provider-1',
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
'LogicFunctionTriggerJob',
|
||||
{
|
||||
logicFunctionId: 'function-2',
|
||||
workspaceId: 'workspace-1',
|
||||
payload: {
|
||||
connectionProviderId: 'provider-1',
|
||||
connectionProviderName: 'linear',
|
||||
connectedAccountId: 'account-1',
|
||||
},
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports to Sentry without throwing when the provider no longer exists', async () => {
|
||||
connectionProviderService.findOneByIdOrThrow.mockRejectedValue(
|
||||
new Error('Provider not found'),
|
||||
);
|
||||
|
||||
await service.dispatchOnDisconnect({
|
||||
connectionProviderId: 'provider-1',
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: 'account-1',
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+16
-118
@@ -16,19 +16,16 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
|
||||
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
|
||||
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
|
||||
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`;
|
||||
|
||||
@@ -51,9 +48,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
findOne: jest.Mock;
|
||||
findOneByOrFail: jest.Mock;
|
||||
};
|
||||
let workspaceCacheService: { getOrRecompute: jest.Mock };
|
||||
let messageQueueService: { add: jest.Mock };
|
||||
let exceptionHandlerService: { captureExceptions: jest.Mock };
|
||||
let connectionProviderLifecycleHookService: { dispatchOnConnect: jest.Mock };
|
||||
|
||||
const baseProvider: ConnectionProviderEntity = {
|
||||
id: 'provider-1',
|
||||
@@ -102,13 +97,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
provider: ConnectedAccountProvider.APP,
|
||||
})),
|
||||
};
|
||||
workspaceCacheService = {
|
||||
getOrRecompute: jest.fn(async () => ({
|
||||
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
|
||||
})),
|
||||
connectionProviderLifecycleHookService = {
|
||||
dispatchOnConnect: jest.fn(),
|
||||
};
|
||||
messageQueueService = { add: jest.fn() };
|
||||
exceptionHandlerService = { captureExceptions: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -128,16 +119,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
useValue: connectedAccountRepository,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: workspaceCacheService,
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(MessageQueue.logicFunctionQueue),
|
||||
useValue: messageQueueService,
|
||||
},
|
||||
{
|
||||
provide: ExceptionHandlerService,
|
||||
useValue: exceptionHandlerService,
|
||||
provide: ConnectionProviderLifecycleHookService,
|
||||
useValue: connectionProviderLifecycleHookService,
|
||||
},
|
||||
{
|
||||
// Real prefix/round-trip behavior is asserted in
|
||||
@@ -464,103 +447,18 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
).rejects.toThrow(/state/);
|
||||
});
|
||||
|
||||
describe('on-connect hook', () => {
|
||||
const ON_CONNECT_UID = 'c1c1c1c1-c1c1-4c1c-c1c1-c1c1c1c1c1c1';
|
||||
|
||||
it('does not dispatch a hook when the provider declares none', async () => {
|
||||
await service.completeAuthorizationFlow({
|
||||
code: 'auth_code',
|
||||
state: 'signed-state',
|
||||
});
|
||||
|
||||
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
it('hands the created connection to the on-connect lifecycle hook', async () => {
|
||||
const result = await service.completeAuthorizationFlow({
|
||||
code: 'auth_code',
|
||||
state: 'signed-state',
|
||||
});
|
||||
|
||||
it('enqueues the declared on-connect logic function in the connecting workspace', async () => {
|
||||
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
});
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[ON_CONNECT_UID]: { id: 'logic-function-1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.completeAuthorizationFlow({
|
||||
code: 'auth_code',
|
||||
state: 'signed-state',
|
||||
});
|
||||
|
||||
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
['flatLogicFunctionMaps'],
|
||||
);
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
'LogicFunctionTriggerJob',
|
||||
{
|
||||
logicFunctionId: 'logic-function-1',
|
||||
workspaceId: 'workspace-1',
|
||||
payload: {
|
||||
connectionProviderId: 'provider-1',
|
||||
connectionProviderName: 'linear',
|
||||
connectedAccountId: result.connectedAccountId,
|
||||
},
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports to Sentry without failing the connection when the hook function is missing', async () => {
|
||||
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
});
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
|
||||
});
|
||||
|
||||
const result = await service.completeAuthorizationFlow({
|
||||
code: 'auth_code',
|
||||
state: 'signed-state',
|
||||
});
|
||||
|
||||
expect(result.connectedAccountId).toBe('new-account-id');
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a soft-deleted hook function as missing instead of enqueuing it', async () => {
|
||||
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
|
||||
...baseProvider,
|
||||
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
|
||||
});
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatLogicFunctionMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[ON_CONNECT_UID]: {
|
||||
id: 'logic-function-1',
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.completeAuthorizationFlow({
|
||||
code: 'auth_code',
|
||||
state: 'signed-state',
|
||||
});
|
||||
|
||||
expect(result.connectedAccountId).toBe('new-account-id');
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
connectionProviderLifecycleHookService.dispatchOnConnect,
|
||||
).toHaveBeenCalledWith({
|
||||
provider: baseProvider,
|
||||
workspaceId: 'workspace-1',
|
||||
connectedAccountId: result.connectedAccountId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -10,4 +10,5 @@ export enum ConnectionProviderExceptionCode {
|
||||
CONNECTION_PROVIDER_NOT_FOUND = 'CONNECTION_PROVIDER_NOT_FOUND',
|
||||
CONNECTION_PROVIDER_NAME_ALREADY_EXISTS = 'CONNECTION_PROVIDER_NAME_ALREADY_EXISTS',
|
||||
ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND = 'ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND',
|
||||
ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND = 'ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND',
|
||||
}
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
|
||||
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
|
||||
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import {
|
||||
LogicFunctionTriggerJob,
|
||||
type LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
type ConnectionLifecycleHook = 'onConnect' | 'onDisconnect';
|
||||
|
||||
const MISSING_LOGIC_FUNCTION_EXCEPTION_CODE_BY_HOOK: Record<
|
||||
ConnectionLifecycleHook,
|
||||
ConnectionProviderExceptionCode
|
||||
> = {
|
||||
onConnect:
|
||||
ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND,
|
||||
onDisconnect:
|
||||
ConnectionProviderExceptionCode.ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ConnectionProviderLifecycleHookService {
|
||||
constructor(
|
||||
private readonly connectionProviderService: ConnectionProviderService,
|
||||
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
async dispatchOnConnect({
|
||||
provider,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
}: {
|
||||
provider: Pick<
|
||||
ConnectionProviderEntity,
|
||||
'id' | 'name' | 'onConnectLogicFunctionUniversalIdentifier'
|
||||
>;
|
||||
workspaceId: string;
|
||||
connectedAccountId: string;
|
||||
}): Promise<void> {
|
||||
await this.captureFailures(workspaceId, () =>
|
||||
this.enqueueLogicFunction({
|
||||
hook: 'onConnect',
|
||||
logicFunctionUniversalIdentifier:
|
||||
provider.onConnectLogicFunctionUniversalIdentifier,
|
||||
provider,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async dispatchOnDisconnect({
|
||||
connectionProviderId,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
}: {
|
||||
connectionProviderId: string;
|
||||
workspaceId: string;
|
||||
connectedAccountId: string;
|
||||
}): Promise<void> {
|
||||
await this.captureFailures(workspaceId, async () => {
|
||||
const provider =
|
||||
await this.connectionProviderService.findOneByIdOrThrow(
|
||||
connectionProviderId,
|
||||
);
|
||||
|
||||
await this.enqueueLogicFunction({
|
||||
hook: 'onDisconnect',
|
||||
logicFunctionUniversalIdentifier:
|
||||
provider.onDisconnectLogicFunctionUniversalIdentifier,
|
||||
provider,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Lifecycle hooks are best effort: a failing hook must never surface to the
|
||||
// user connecting or disconnecting their account.
|
||||
private async captureFailures(
|
||||
workspaceId: string,
|
||||
dispatch: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await dispatch();
|
||||
} catch (error) {
|
||||
this.exceptionHandlerService.captureExceptions([error], {
|
||||
workspace: { id: workspaceId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueLogicFunction({
|
||||
hook,
|
||||
logicFunctionUniversalIdentifier,
|
||||
provider,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
}: {
|
||||
hook: ConnectionLifecycleHook;
|
||||
logicFunctionUniversalIdentifier: string | null;
|
||||
provider: Pick<ConnectionProviderEntity, 'id' | 'name'>;
|
||||
workspaceId: string;
|
||||
connectedAccountId: string;
|
||||
}): Promise<void> {
|
||||
if (!isDefined(logicFunctionUniversalIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatLogicFunctionMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatLogicFunctionMaps',
|
||||
]);
|
||||
|
||||
const flatLogicFunction =
|
||||
flatLogicFunctionMaps.byUniversalIdentifier[
|
||||
logicFunctionUniversalIdentifier
|
||||
];
|
||||
|
||||
if (
|
||||
!isDefined(flatLogicFunction) ||
|
||||
isDefined(flatLogicFunction.deletedAt)
|
||||
) {
|
||||
throw new ConnectionProviderException(
|
||||
`Connection provider ${provider.id} references ${hook} logic function ${logicFunctionUniversalIdentifier}, which was not found in workspace ${workspaceId}.`,
|
||||
MISSING_LOGIC_FUNCTION_EXCEPTION_CODE_BY_HOOK[hook],
|
||||
);
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<LogicFunctionTriggerJobData>(
|
||||
LogicFunctionTriggerJob.name,
|
||||
{
|
||||
logicFunctionId: flatLogicFunction.id,
|
||||
workspaceId,
|
||||
payload: {
|
||||
connectionProviderId: provider.id,
|
||||
connectionProviderName: provider.name,
|
||||
connectedAccountId,
|
||||
},
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-73
@@ -7,6 +7,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
|
||||
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
|
||||
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
|
||||
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
|
||||
@@ -21,20 +22,11 @@ import { exchangeCodeForToken } from 'src/engine/core-modules/application/connec
|
||||
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
|
||||
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import {
|
||||
LogicFunctionTriggerJob,
|
||||
type LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const STATE_JWT_EXPIRES_IN = '10m';
|
||||
|
||||
@@ -70,12 +62,9 @@ export class ConnectionProviderOAuthFlowService {
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
|
||||
private readonly connectionProviderLifecycleHookService: ConnectionProviderLifecycleHookService,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
async startAuthorizationFlow(
|
||||
@@ -199,7 +188,7 @@ export class ConnectionProviderOAuthFlowService {
|
||||
statePayload.reconnectingConnectedAccountId,
|
||||
});
|
||||
|
||||
await this.dispatchOnConnectHook({
|
||||
await this.connectionProviderLifecycleHookService.dispatchOnConnect({
|
||||
provider,
|
||||
workspaceId: statePayload.workspaceId,
|
||||
connectedAccountId: connectedAccount.id,
|
||||
@@ -213,65 +202,6 @@ export class ConnectionProviderOAuthFlowService {
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchOnConnectHook({
|
||||
provider,
|
||||
workspaceId,
|
||||
connectedAccountId,
|
||||
}: {
|
||||
provider: OAuthConnectionProvider;
|
||||
workspaceId: string;
|
||||
connectedAccountId: string;
|
||||
}): Promise<void> {
|
||||
const { onConnectLogicFunctionUniversalIdentifier } = provider;
|
||||
|
||||
if (!isDefined(onConnectLogicFunctionUniversalIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The on-connect hook is best-effort: the ConnectedAccount is already
|
||||
// persisted, so a misconfigured or failing hook must not break the OAuth
|
||||
// callback. We still report failures to Sentry so they don't go unnoticed.
|
||||
try {
|
||||
const { flatLogicFunctionMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatLogicFunctionMaps',
|
||||
]);
|
||||
|
||||
const flatLogicFunction =
|
||||
flatLogicFunctionMaps.byUniversalIdentifier[
|
||||
onConnectLogicFunctionUniversalIdentifier
|
||||
];
|
||||
|
||||
if (
|
||||
!isDefined(flatLogicFunction) ||
|
||||
isDefined(flatLogicFunction.deletedAt)
|
||||
) {
|
||||
throw new ConnectionProviderException(
|
||||
`Connection provider ${provider.id} references on-connect logic function ${onConnectLogicFunctionUniversalIdentifier}, which was not found in workspace ${workspaceId}.`,
|
||||
ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<LogicFunctionTriggerJobData>(
|
||||
LogicFunctionTriggerJob.name,
|
||||
{
|
||||
logicFunctionId: flatLogicFunction.id,
|
||||
workspaceId,
|
||||
payload: {
|
||||
connectionProviderId: provider.id,
|
||||
connectionProviderName: provider.name,
|
||||
connectedAccountId,
|
||||
},
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
} catch (error) {
|
||||
this.exceptionHandlerService.captureExceptions([error], {
|
||||
workspace: { id: workspaceId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async signState(payload: AppOAuthStateJwtPayload): Promise<string> {
|
||||
return this.jwtWrapperService.signAsyncOrThrow(payload, {
|
||||
expiresIn: STATE_JWT_EXPIRES_IN,
|
||||
|
||||
+7
@@ -47,6 +47,13 @@ export class ConnectionProviderEntity
|
||||
})
|
||||
onConnectLogicFunctionUniversalIdentifier: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.27.0_AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand_1785810340935',
|
||||
})
|
||||
onDisconnectLogicFunctionUniversalIdentifier: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ const getConnectionProviderExceptionUserFriendlyMessage = (
|
||||
return msg`A connection provider with this name already exists for this application.`;
|
||||
case ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`The logic function to run on connect was not found.`;
|
||||
case ConnectionProviderExceptionCode.ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`The logic function to run on disconnect was not found.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+7
-1
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/connection-provider/application-connection-provider.resolver';
|
||||
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
|
||||
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
|
||||
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
@@ -35,8 +36,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
providers: [
|
||||
ConnectionProviderService,
|
||||
ConnectionProviderOAuthFlowService,
|
||||
ConnectionProviderLifecycleHookService,
|
||||
ApplicationConnectionProviderResolver,
|
||||
],
|
||||
exports: [ConnectionProviderService, ConnectionProviderOAuthFlowService],
|
||||
exports: [
|
||||
ConnectionProviderService,
|
||||
ConnectionProviderOAuthFlowService,
|
||||
ConnectionProviderLifecycleHookService,
|
||||
],
|
||||
})
|
||||
export class ConnectionProviderModule {}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
|
||||
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
@@ -21,6 +22,7 @@ import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/
|
||||
MessageChannelEntity,
|
||||
]),
|
||||
AppOAuthRefreshModule,
|
||||
ConnectionProviderModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
|
||||
+12
@@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
|
||||
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
|
||||
import { CALENDAR_CHANNEL_DELETED_EVENT } from 'src/engine/metadata-modules/calendar-channel/constants/calendar-channel-deleted.constant';
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
@@ -31,6 +34,7 @@ export class ConnectedAccountMetadataService {
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
|
||||
private readonly appOAuthRevokeService: AppOAuthRevokeService,
|
||||
private readonly connectionProviderLifecycleHookService: ConnectionProviderLifecycleHookService,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
) {}
|
||||
|
||||
@@ -246,6 +250,14 @@ export class ConnectedAccountMetadataService {
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
if (isDefined(connectedAccount.connectionProviderId)) {
|
||||
await this.connectionProviderLifecycleHookService.dispatchOnDisconnect({
|
||||
connectionProviderId: connectedAccount.connectionProviderId,
|
||||
workspaceId,
|
||||
connectedAccountId: id,
|
||||
});
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<MessageChannelDeletedEvent>(
|
||||
MESSAGE_CHANNEL_DELETED_EVENT,
|
||||
messageChannels.map((messageChannel) => ({
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"type",
|
||||
"oauthConfig",
|
||||
"onConnectLogicFunctionUniversalIdentifier",
|
||||
"onDisconnectLogicFunctionUniversalIdentifier",
|
||||
],
|
||||
"propertiesToStringify": [
|
||||
"oauthConfig",
|
||||
|
||||
+5
@@ -1813,6 +1813,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
onDisconnectLogicFunctionUniversalIdentifier: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
createdAt: {
|
||||
toCompare: false,
|
||||
toStringify: false,
|
||||
|
||||
+1
@@ -84,6 +84,7 @@ describe('Manifest update - connection providers', () => {
|
||||
usePkce: true,
|
||||
},
|
||||
onConnectLogicFunctionUniversalIdentifier: null,
|
||||
onDisconnectLogicFunctionUniversalIdentifier: null,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ type ConnectionProviderRow = {
|
||||
type: string;
|
||||
oauthConfig: StoredOAuthConnectionProviderConfig | null;
|
||||
onConnectLogicFunctionUniversalIdentifier: string | null;
|
||||
onDisconnectLogicFunctionUniversalIdentifier: string | null;
|
||||
};
|
||||
|
||||
export const findConnectionProvidersByApplication = async (
|
||||
@@ -18,7 +19,8 @@ export const findConnectionProvidersByApplication = async (
|
||||
return globalThis.testDataSource.query(
|
||||
`SELECT cp.id, cp."universalIdentifier", cp."applicationId",
|
||||
cp."workspaceId", cp.name, cp."displayName", cp.type,
|
||||
cp."oauthConfig", cp."onConnectLogicFunctionUniversalIdentifier"
|
||||
cp."oauthConfig", cp."onConnectLogicFunctionUniversalIdentifier",
|
||||
cp."onDisconnectLogicFunctionUniversalIdentifier"
|
||||
FROM core."connectionProvider" cp
|
||||
JOIN core."application" app ON app.id = cp."applicationId"
|
||||
WHERE app."universalIdentifier" = $1
|
||||
|
||||
@@ -7,4 +7,5 @@ export type ConnectionProviderManifest = SyncableEntityOptions & {
|
||||
type: 'oauth';
|
||||
oauth: OAuthConnectionProviderConfig;
|
||||
onConnectLogicFunction?: SyncableEntityOptions;
|
||||
onDisconnectLogicFunction?: SyncableEntityOptions;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user