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:
Abdul Rahman
2026-08-04 08:17:23 +05:30
committed by GitHub
parent 5ffa121e59
commit 3a646ffcb0
24 changed files with 594 additions and 244 deletions
@@ -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,
@@ -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) => ({
@@ -61,6 +61,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"type",
"oauthConfig",
"onConnectLogicFunctionUniversalIdentifier",
"onDisconnectLogicFunctionUniversalIdentifier",
],
"propertiesToStringify": [
"oauthConfig",
@@ -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,