refactor reconnect account logic (#15584)
This commit is contained in:
+1
@@ -149,6 +149,7 @@ export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = {
|
||||
accountOwner: '20202020-3517-4896-afac-b1d0aa362af6',
|
||||
lastSyncHistoryId: '20202020-115c-4a87-b50f-ac4367a971b9',
|
||||
authFailedAt: '20202020-d268-4c6b-baff-400d402b430a',
|
||||
lastCredentialsRefreshedAt: '20202020-aa5e-4e85-903b-fdf90a941941',
|
||||
messageChannels: '20202020-24f7-4362-8468-042204d1e445',
|
||||
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
||||
handleAliases: '20202020-8a3d-46be-814f-6228af16c47b',
|
||||
|
||||
+7
-2
@@ -10,19 +10,21 @@ import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repos
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
|
||||
import { CalendarEventCleanerModule } from 'src/modules/calendar/calendar-event-cleaner/calendar-event-cleaner.module';
|
||||
import { CalendarRelaunchFailedCalendarChannelsCommand } from 'src/modules/calendar/calendar-event-import-manager/commands/calendar-relaunch-failed-calendar-channels.command';
|
||||
import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command';
|
||||
import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command';
|
||||
import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command';
|
||||
import { CalendarRelaunchFailedCalendarChannelsCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-relaunch-failed-calendar-channels.cron.command';
|
||||
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
|
||||
import { CalendarEventsImportCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
|
||||
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
|
||||
import { CalendarRelaunchFailedCalendarChannelsCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-relaunch-failed-calendar-channels.cron.job';
|
||||
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
|
||||
import { GoogleCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/google-calendar-driver.module';
|
||||
import { MicrosoftCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module';
|
||||
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
|
||||
import { CalendarEventsImportJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job';
|
||||
import { CalendarOngoingStaleJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-ongoing-stale.job';
|
||||
import { CalendarRelaunchFailedCalendarChannelJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-relaunch-failed-calendar-channel.job';
|
||||
import { CalendarAccountAuthenticationService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-account-authentication.service';
|
||||
import { CalendarEventImportErrorHandlerService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service';
|
||||
import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
|
||||
@@ -71,8 +73,10 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
|
||||
CalendarEventsImportJob,
|
||||
CalendarOngoingStaleCronJob,
|
||||
CalendarOngoingStaleCronCommand,
|
||||
CalendarRelaunchFailedCalendarChannelsCommand,
|
||||
CalendarOngoingStaleJob,
|
||||
CalendarRelaunchFailedCalendarChannelsCronJob,
|
||||
CalendarRelaunchFailedCalendarChannelsCronCommand,
|
||||
CalendarRelaunchFailedCalendarChannelJob,
|
||||
],
|
||||
exports: [
|
||||
CalendarEventsImportService,
|
||||
@@ -80,6 +84,7 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
|
||||
CalendarEventListFetchCronCommand,
|
||||
CalendarEventsImportCronCommand,
|
||||
CalendarOngoingStaleCronCommand,
|
||||
CalendarRelaunchFailedCalendarChannelsCronCommand,
|
||||
],
|
||||
})
|
||||
export class CalendarEventImportManagerModule {}
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
type RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
CalendarChannelSyncStatus,
|
||||
CalendarChannelWorkspaceEntity,
|
||||
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
|
||||
|
||||
@Command({
|
||||
name: 'calendar:relaunch-failed-calendar-channels',
|
||||
description: 'Relaunch failed message channels',
|
||||
})
|
||||
export class CalendarRelaunchFailedCalendarChannelsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
protected readonly accountsToReconnectService: AccountsToReconnectService,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
try {
|
||||
const calendarChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<CalendarChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'calendarChannel',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const failedCalendarChannels = await calendarChannelRepository.find({
|
||||
where: {
|
||||
syncStage: CalendarChannelSyncStage.FAILED,
|
||||
},
|
||||
relations: {
|
||||
connectedAccount: {
|
||||
accountOwner: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!options.dryRun && failedCalendarChannels.length > 0) {
|
||||
await calendarChannelRepository.update(
|
||||
failedCalendarChannels.map(({ id }) => id),
|
||||
{
|
||||
syncStage:
|
||||
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
|
||||
syncStatus: CalendarChannelSyncStatus.ACTIVE,
|
||||
},
|
||||
);
|
||||
|
||||
for (const failedCalendarChannel of failedCalendarChannels) {
|
||||
await this.accountsToReconnectService.removeAccountToReconnect(
|
||||
failedCalendarChannel.connectedAccount.accountOwner.userId,
|
||||
failedCalendarChannel.connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${options.dryRun ? ' (DRY RUN): ' : ''}Relaunched ${failedCalendarChannels.length} failed calendar channels`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Error while relaunching failed message channels',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
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 {
|
||||
CALENDAR_RELAUNCH_FAILED_CALENDAR_CHANNELS_CRON_PATTERN,
|
||||
CalendarRelaunchFailedCalendarChannelsCronJob,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-relaunch-failed-calendar-channels.cron.job';
|
||||
|
||||
@Command({
|
||||
name: 'cron:calendar:relaunch-failed-calendar-channels',
|
||||
description:
|
||||
'Starts a cron job to relaunch failed calendar channels every 30 minutes',
|
||||
})
|
||||
export class CalendarRelaunchFailedCalendarChannelsCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CalendarRelaunchFailedCalendarChannelsCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: CALENDAR_RELAUNCH_FAILED_CALENDAR_CHANNELS_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import {
|
||||
CalendarRelaunchFailedCalendarChannelJob,
|
||||
type CalendarRelaunchFailedCalendarChannelJobData,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-relaunch-failed-calendar-channel.job';
|
||||
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
|
||||
export const CALENDAR_RELAUNCH_FAILED_CALENDAR_CHANNELS_CRON_PATTERN =
|
||||
'*/30 * * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class CalendarRelaunchFailedCalendarChannelsCronJob {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectMessageQueue(MessageQueue.calendarQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
@Process(CalendarRelaunchFailedCalendarChannelsCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
CalendarRelaunchFailedCalendarChannelsCronJob.name,
|
||||
CALENDAR_RELAUNCH_FAILED_CALENDAR_CHANNELS_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
const activeWorkspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
for (const activeWorkspace of activeWorkspaces) {
|
||||
try {
|
||||
const schemaName = getWorkspaceSchemaName(activeWorkspace.id);
|
||||
|
||||
const failedCalendarChannels = await this.coreDataSource.query(
|
||||
`SELECT * FROM ${schemaName}."calendarChannel" WHERE "syncStage" = '${CalendarChannelSyncStage.FAILED}'`,
|
||||
);
|
||||
|
||||
for (const calendarChannel of failedCalendarChannels) {
|
||||
await this.messageQueueService.add<CalendarRelaunchFailedCalendarChannelJobData>(
|
||||
CalendarRelaunchFailedCalendarChannelJob.name,
|
||||
{
|
||||
workspaceId: activeWorkspace.id,
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.exceptionHandlerService.captureExceptions([error], {
|
||||
workspace: {
|
||||
id: activeWorkspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Scope } from '@nestjs/common';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
CalendarChannelSyncStatus,
|
||||
CalendarChannelWorkspaceEntity,
|
||||
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
|
||||
export type CalendarRelaunchFailedCalendarChannelJobData = {
|
||||
workspaceId: string;
|
||||
calendarChannelId: string;
|
||||
};
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.calendarQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class CalendarRelaunchFailedCalendarChannelJob {
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
@Process(CalendarRelaunchFailedCalendarChannelJob.name)
|
||||
async handle(data: CalendarRelaunchFailedCalendarChannelJobData) {
|
||||
const { workspaceId, calendarChannelId } = data;
|
||||
|
||||
const calendarChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<CalendarChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'calendarChannel',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const calendarChannel = await calendarChannelRepository.findOne({
|
||||
where: {
|
||||
id: calendarChannelId,
|
||||
},
|
||||
relations: {
|
||||
connectedAccount: {
|
||||
accountOwner: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!calendarChannel ||
|
||||
calendarChannel.syncStage !== CalendarChannelSyncStage.FAILED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarChannelRepository.update(calendarChannelId, {
|
||||
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
|
||||
syncStatus: CalendarChannelSyncStatus.ACTIVE,
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import { GaxiosError } from 'gaxios';
|
||||
import { google } from 'googleapis';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
@@ -23,19 +24,32 @@ export class GoogleAPIRefreshAccessTokenService {
|
||||
oAuth2Client.setCredentials({
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
try {
|
||||
const { token } = await oAuth2Client.getAccessToken();
|
||||
|
||||
const { token } = await oAuth2Client.getAccessToken();
|
||||
if (!isDefined(token)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
'Error refreshing Google tokens: Invalid refresh token',
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(token)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
'Failed to refresh google access token',
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
|
||||
);
|
||||
return {
|
||||
accessToken: token,
|
||||
refreshToken,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof GaxiosError &&
|
||||
error.response?.data?.error === 'invalid_grant'
|
||||
) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
'Error refreshing Google tokens: Invalid refresh token',
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token as string,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+39
-26
@@ -1,9 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import axios from 'axios';
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode,
|
||||
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
|
||||
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||
|
||||
export type MicrosoftTokens = {
|
||||
@@ -19,36 +22,46 @@ interface MicrosoftRefreshTokenResponse {
|
||||
expires_in: number;
|
||||
id_token?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftAPIRefreshAccessTokenService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
|
||||
const response = await axios.post<MicrosoftRefreshTokenResponse>(
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||
new URLSearchParams({
|
||||
client_id: this.twentyConfigService.get('AUTH_MICROSOFT_CLIENT_ID'),
|
||||
client_secret: this.twentyConfigService.get(
|
||||
'AUTH_MICROSOFT_CLIENT_SECRET',
|
||||
),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
try {
|
||||
const response = await axios.post<MicrosoftRefreshTokenResponse>(
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||
new URLSearchParams({
|
||||
client_id: this.twentyConfigService.get('AUTH_MICROSOFT_CLIENT_ID'),
|
||||
client_secret: this.twentyConfigService.get(
|
||||
'AUTH_MICROSOFT_CLIENT_SECRET',
|
||||
),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
const responseData = response.data as MicrosoftRefreshTokenResponse;
|
||||
|
||||
z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
}).parse(response.data);
|
||||
|
||||
return {
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token,
|
||||
};
|
||||
return {
|
||||
accessToken: responseData.access_token,
|
||||
refreshToken: responseData.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AxiosError &&
|
||||
error.response?.data?.error === 'invalid_grant'
|
||||
) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Failed to refresh Microsoft token: ${error.response?.data?.error} - ${error.response?.data?.error_description}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export const isAccessTokenExpiredOrInvalid = (
|
||||
token: string,
|
||||
expirationBufferInSeconds = 5 * 60,
|
||||
): boolean => {
|
||||
if (!isNonEmptyString(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.decode(token) as { exp?: number } | null;
|
||||
|
||||
if (!payload || typeof payload.exp !== 'number') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
return payload.exp < currentTime + expirationBufferInSeconds;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
+1
@@ -4,6 +4,7 @@ export class ConnectedAccountRefreshAccessTokenException extends CustomException
|
||||
|
||||
export enum ConnectedAccountRefreshAccessTokenExceptionCode {
|
||||
REFRESH_TOKEN_NOT_FOUND = 'REFRESH_TOKEN_NOT_FOUND',
|
||||
INVALID_REFRESH_TOKEN = 'INVALID_REFRESH_TOKEN',
|
||||
REFRESH_ACCESS_TOKEN_FAILED = 'REFRESH_ACCESS_TOKEN_FAILED',
|
||||
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
|
||||
TEMPORARY_NETWORK_ERROR = 'TEMPORARY_NETWORK_ERROR',
|
||||
|
||||
+181
-22
@@ -5,7 +5,6 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
|
||||
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
|
||||
import { isAccessTokenExpiredOrInvalid } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/is-access-token-expired-or-invalid.util';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode,
|
||||
@@ -14,12 +13,9 @@ import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-acco
|
||||
|
||||
import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service';
|
||||
|
||||
jest.mock(
|
||||
'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/is-access-token-expired-or-invalid.util',
|
||||
);
|
||||
|
||||
describe('ConnectedAccountRefreshTokensService', () => {
|
||||
let service: ConnectedAccountRefreshTokensService;
|
||||
let googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService;
|
||||
let microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService;
|
||||
let twentyORMManager: TwentyORMManager;
|
||||
|
||||
@@ -57,6 +53,10 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
service = module.get<ConnectedAccountRefreshTokensService>(
|
||||
ConnectedAccountRefreshTokensService,
|
||||
);
|
||||
googleAPIRefreshAccessTokenService =
|
||||
module.get<GoogleAPIRefreshAccessTokenService>(
|
||||
GoogleAPIRefreshAccessTokenService,
|
||||
);
|
||||
microsoftAPIRefreshAccessTokenService =
|
||||
module.get<MicrosoftAPIRefreshAccessTokenService>(
|
||||
MicrosoftAPIRefreshAccessTokenService,
|
||||
@@ -69,16 +69,15 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
});
|
||||
|
||||
describe('refreshAndSaveTokens', () => {
|
||||
it('should reuse valid access token without refreshing', async () => {
|
||||
it('should reuse valid access token without refreshing when lastCredentialsRefreshedAt is recent', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
(isAccessTokenExpiredOrInvalid as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
@@ -94,12 +93,13 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
expect(twentyORMManager.getRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh and save new Microsoft token when expired', async () => {
|
||||
it('should refresh and save new Microsoft token when expired (lastCredentialsRefreshedAt is old)', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const mockRepository = { update: jest.fn() };
|
||||
@@ -108,7 +108,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
refreshToken: mockRefreshToken,
|
||||
};
|
||||
|
||||
(isAccessTokenExpiredOrInvalid as jest.Mock).mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
@@ -127,7 +126,90 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
).toHaveBeenCalledWith(mockRefreshToken);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith(
|
||||
{ id: mockConnectedAccountId },
|
||||
newTokens,
|
||||
expect.objectContaining({
|
||||
...newTokens,
|
||||
lastCredentialsRefreshedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should refresh and save new Google token when expired (lastCredentialsRefreshedAt is old)', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const mockRepository = { update: jest.fn() };
|
||||
const newTokens = {
|
||||
accessToken: mockNewAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
jest
|
||||
.spyOn(twentyORMManager, 'getRepository')
|
||||
.mockResolvedValue(mockRepository as any);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual(newTokens);
|
||||
expect(
|
||||
googleAPIRefreshAccessTokenService.refreshTokens,
|
||||
).toHaveBeenCalledWith(mockRefreshToken);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith(
|
||||
{ id: mockConnectedAccountId },
|
||||
expect.objectContaining({
|
||||
...newTokens,
|
||||
lastCredentialsRefreshedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should refresh token when lastCredentialsRefreshedAt is null', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const mockRepository = { update: jest.fn() };
|
||||
const newTokens = {
|
||||
accessToken: mockNewAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
jest
|
||||
.spyOn(twentyORMManager, 'getRepository')
|
||||
.mockResolvedValue(mockRepository as any);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual(newTokens);
|
||||
expect(
|
||||
microsoftAPIRefreshAccessTokenService.refreshTokens,
|
||||
).toHaveBeenCalledWith(mockRefreshToken);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith(
|
||||
{ id: mockConnectedAccountId },
|
||||
expect.objectContaining({
|
||||
...newTokens,
|
||||
lastCredentialsRefreshedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -137,6 +219,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: null,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
} as unknown as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
await expect(
|
||||
@@ -149,30 +232,106 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when Microsoft refresh fails with axios error', async () => {
|
||||
it('should throw exception when Microsoft refresh fails with invalid_grant', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const axiosError = {
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'invalid_grant', error_description: 'Token expired' },
|
||||
},
|
||||
};
|
||||
const invalidGrantError = new ConnectedAccountRefreshAccessTokenException(
|
||||
'Microsoft OAuth error: invalid_grant - Token has been revoked',
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
(isAccessTokenExpiredOrInvalid as jest.Mock).mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockRejectedValue(axiosError);
|
||||
.mockRejectedValue(invalidGrantError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toThrow(ConnectedAccountRefreshAccessTokenException);
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
'Microsoft OAuth error: invalid_grant - Token has been revoked',
|
||||
),
|
||||
code: ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw TEMPORARY_NETWORK_ERROR when refresh fails with network error', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const networkError = new Error('Network error');
|
||||
|
||||
(networkError as any).code = 'ECONNRESET';
|
||||
|
||||
jest
|
||||
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccessTokenStillValid', () => {
|
||||
it('should return true when lastCredentialsRefreshedAt is within the valid window (30 minutes ago)', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const result = await service.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when lastCredentialsRefreshedAt is outside the valid window (2 hours ago)', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const result = await service.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when lastCredentialsRefreshedAt is null', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const result = await service.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for IMAP_SMTP_CALDAV provider regardless of lastCredentialsRefreshedAt', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const result = await service.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+22
-30
@@ -6,7 +6,6 @@ import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
|
||||
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
|
||||
import { isAccessTokenExpiredOrInvalid } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/is-access-token-expired-or-invalid.util';
|
||||
import {
|
||||
ConnectedAccountRefreshAccessTokenException,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode,
|
||||
@@ -19,6 +18,8 @@ export type ConnectedAccountTokens = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60;
|
||||
|
||||
@Injectable()
|
||||
export class ConnectedAccountRefreshTokensService {
|
||||
private readonly logger = new Logger(
|
||||
@@ -44,10 +45,8 @@ export class ConnectedAccountRefreshTokensService {
|
||||
);
|
||||
}
|
||||
|
||||
const isAccessTokenValid = await this.isAccessTokenStillValid(
|
||||
connectedAccount,
|
||||
accessToken,
|
||||
);
|
||||
const isAccessTokenValid =
|
||||
await this.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
if (isAccessTokenValid) {
|
||||
this.logger.debug(
|
||||
@@ -77,7 +76,10 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
await connectedAccountRepository.update(
|
||||
{ id: connectedAccount.id },
|
||||
connectedAccountTokens,
|
||||
{
|
||||
...connectedAccountTokens,
|
||||
lastCredentialsRefreshedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
return connectedAccountTokens;
|
||||
@@ -85,17 +87,23 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
async isAccessTokenStillValid(
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
accessToken: string,
|
||||
): Promise<boolean> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE: {
|
||||
// Google's access tokens are opaque and needs network calls to check if they are valid we default to false for now
|
||||
return false;
|
||||
}
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
case ConnectedAccountProvider.MICROSOFT: {
|
||||
const isExpired = isAccessTokenExpiredOrInvalid(accessToken);
|
||||
if (!connectedAccount.lastCredentialsRefreshedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isExpired;
|
||||
const BUFFER_TIME = 5 * 60 * 1000;
|
||||
|
||||
const tokenExpirationTime =
|
||||
CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION - BUFFER_TIME;
|
||||
|
||||
return (
|
||||
connectedAccount.lastCredentialsRefreshedAt >
|
||||
new Date(Date.now() - tokenExpirationTime)
|
||||
);
|
||||
}
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return true;
|
||||
@@ -134,19 +142,6 @@ export class ConnectedAccountRefreshTokensService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AggregateError') {
|
||||
const firstError = error?.errors?.[0];
|
||||
|
||||
this.logger.log(firstError);
|
||||
|
||||
if (isAxiosTemporaryError(error)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Error refreshing tokens for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}: ${firstError.code}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAxiosTemporaryError(error)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Error refreshing tokens for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}: ${error.code}`,
|
||||
@@ -158,10 +153,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
`Error while refreshing tokens on connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
|
||||
error,
|
||||
);
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Error refreshing tokens for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}: ${error.message} ${error?.response?.data?.error_description}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -70,6 +70,16 @@ export class ConnectedAccountWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
})
|
||||
refreshToken: string;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.lastCredentialsRefreshedAt,
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: msg`Last credentials refreshed at`,
|
||||
description: msg`Last credentials refreshed at`,
|
||||
icon: 'IconHistory',
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
lastCredentialsRefreshedAt: Date | null;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.lastSyncHistoryId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
|
||||
+26
-22
@@ -190,31 +190,35 @@ export class MessageChannelSyncStatusService {
|
||||
eventIds: messageChannelIds,
|
||||
});
|
||||
|
||||
const connectedAccountRepository =
|
||||
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
'connectedAccount',
|
||||
if (
|
||||
syncStatus === MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS
|
||||
) {
|
||||
const connectedAccountRepository =
|
||||
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
'connectedAccount',
|
||||
);
|
||||
|
||||
const messageChannels = await messageChannelRepository.find({
|
||||
select: ['id', 'connectedAccountId'],
|
||||
where: { id: Any(messageChannelIds) },
|
||||
});
|
||||
|
||||
const connectedAccountIds = messageChannels.map(
|
||||
(messageChannel) => messageChannel.connectedAccountId,
|
||||
);
|
||||
|
||||
const messageChannels = await messageChannelRepository.find({
|
||||
select: ['id', 'connectedAccountId'],
|
||||
where: { id: Any(messageChannelIds) },
|
||||
});
|
||||
await connectedAccountRepository.update(
|
||||
{ id: Any(connectedAccountIds) },
|
||||
{
|
||||
authFailedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
const connectedAccountIds = messageChannels.map(
|
||||
(messageChannel) => messageChannel.connectedAccountId,
|
||||
);
|
||||
|
||||
await connectedAccountRepository.update(
|
||||
{ id: Any(connectedAccountIds) },
|
||||
{
|
||||
authFailedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
await this.addToAccountsToReconnect(
|
||||
messageChannels.map((messageChannel) => messageChannel.id),
|
||||
workspaceId,
|
||||
);
|
||||
await this.addToAccountsToReconnect(
|
||||
messageChannels.map((messageChannel) => messageChannel.id),
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addToAccountsToReconnect(
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
type RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelSyncStatus,
|
||||
MessageChannelWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
|
||||
@Command({
|
||||
name: 'messaging:relaunch-failed-message-channels',
|
||||
description: 'Relaunch failed message channels',
|
||||
})
|
||||
export class MessagingRelaunchFailedMessageChannelsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
protected readonly accountsToReconnectService: AccountsToReconnectService,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
try {
|
||||
const messageChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const failedMessageChannels = await messageChannelRepository.find({
|
||||
where: {
|
||||
syncStage: MessageChannelSyncStage.FAILED,
|
||||
},
|
||||
relations: {
|
||||
connectedAccount: {
|
||||
accountOwner: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!options.dryRun && failedMessageChannels.length > 0) {
|
||||
await messageChannelRepository.update(
|
||||
failedMessageChannels.map(({ id }) => id),
|
||||
{
|
||||
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
|
||||
syncStatus: MessageChannelSyncStatus.ACTIVE,
|
||||
},
|
||||
);
|
||||
|
||||
for (const failedMessageChannel of failedMessageChannels) {
|
||||
await this.accountsToReconnectService.removeAccountToReconnect(
|
||||
failedMessageChannel.connectedAccount.accountOwner.userId,
|
||||
failedMessageChannel.connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${options.dryRun ? ' (DRY RUN) ' : ''}Relaunched ${failedMessageChannels.length} failed message channels`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Error while relaunching failed message channels',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
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 {
|
||||
MESSAGING_RELAUNCH_FAILED_MESSAGE_CHANNELS_CRON_PATTERN,
|
||||
MessagingRelaunchFailedMessageChannelsCronJob,
|
||||
} from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
|
||||
|
||||
@Command({
|
||||
name: 'cron:messaging:relaunch-failed-message-channels',
|
||||
description:
|
||||
'Starts a cron job to relaunch failed message channels every 30 minutes',
|
||||
})
|
||||
export class MessagingRelaunchFailedMessageChannelsCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: MessagingRelaunchFailedMessageChannelsCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: MESSAGING_RELAUNCH_FAILED_MESSAGE_CHANNELS_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import { MessageChannelSyncStage } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import {
|
||||
MessagingRelaunchFailedMessageChannelJob,
|
||||
type MessagingRelaunchFailedMessageChannelJobData,
|
||||
} from 'src/modules/messaging/message-import-manager/jobs/messaging-relaunch-failed-message-channel.job';
|
||||
|
||||
export const MESSAGING_RELAUNCH_FAILED_MESSAGE_CHANNELS_CRON_PATTERN =
|
||||
'*/30 * * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class MessagingRelaunchFailedMessageChannelsCronJob {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
@Process(MessagingRelaunchFailedMessageChannelsCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
MessagingRelaunchFailedMessageChannelsCronJob.name,
|
||||
MESSAGING_RELAUNCH_FAILED_MESSAGE_CHANNELS_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
const activeWorkspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
for (const activeWorkspace of activeWorkspaces) {
|
||||
try {
|
||||
const schemaName = getWorkspaceSchemaName(activeWorkspace.id);
|
||||
|
||||
const failedMessageChannels = await this.coreDataSource.query(
|
||||
`SELECT * FROM ${schemaName}."messageChannel" WHERE "syncStage" = '${MessageChannelSyncStage.FAILED}'`,
|
||||
);
|
||||
|
||||
for (const messageChannel of failedMessageChannels) {
|
||||
await this.messageQueueService.add<MessagingRelaunchFailedMessageChannelJobData>(
|
||||
MessagingRelaunchFailedMessageChannelJob.name,
|
||||
{
|
||||
workspaceId: activeWorkspace.id,
|
||||
messageChannelId: messageChannel.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.exceptionHandlerService.captureExceptions([error], {
|
||||
workspace: {
|
||||
id: activeWorkspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Scope } from '@nestjs/common';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelSyncStatus,
|
||||
MessageChannelWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
|
||||
export type MessagingRelaunchFailedMessageChannelJobData = {
|
||||
workspaceId: string;
|
||||
messageChannelId: string;
|
||||
};
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.messagingQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class MessagingRelaunchFailedMessageChannelJob {
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
@Process(MessagingRelaunchFailedMessageChannelJob.name)
|
||||
async handle(data: MessagingRelaunchFailedMessageChannelJobData) {
|
||||
const { workspaceId, messageChannelId } = data;
|
||||
|
||||
const messageChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const messageChannel = await messageChannelRepository.findOne({
|
||||
where: {
|
||||
id: messageChannelId,
|
||||
},
|
||||
relations: {
|
||||
connectedAccount: {
|
||||
accountOwner: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!messageChannel ||
|
||||
messageChannel.syncStage !== MessageChannelSyncStage.FAILED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await messageChannelRepository.update(messageChannelId, {
|
||||
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
|
||||
syncStatus: MessageChannelSyncStatus.ACTIVE,
|
||||
});
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -14,14 +14,15 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
|
||||
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
|
||||
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
|
||||
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
|
||||
import { MessagingRelaunchFailedMessageChannelsCommand } from 'src/modules/messaging/message-import-manager/commands/messaging-relaunch-failed-message-channels.command';
|
||||
import { MessagingSingleMessageImportCommand } from 'src/modules/messaging/message-import-manager/commands/messaging-single-message-import.command';
|
||||
import { MessagingMessageListFetchCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-message-list-fetch.cron.command';
|
||||
import { MessagingMessagesImportCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-messages-import.cron.command';
|
||||
import { MessagingOngoingStaleCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-ongoing-stale.cron.command';
|
||||
import { MessagingRelaunchFailedMessageChannelsCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-relaunch-failed-message-channels.cron.command';
|
||||
import { MessagingMessageListFetchCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job';
|
||||
import { MessagingMessagesImportCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job';
|
||||
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-ongoing-stale.cron.job';
|
||||
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
|
||||
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
|
||||
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
|
||||
@@ -31,6 +32,7 @@ import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-man
|
||||
import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
|
||||
import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
|
||||
import { MessagingRelaunchFailedMessageChannelJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-relaunch-failed-message-channel.job';
|
||||
import { MessagingMessageImportManagerMessageChannelListener } from 'src/modules/messaging/message-import-manager/listeners/messaging-import-manager-message-channel.listener';
|
||||
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||
import { MessagingCursorService } from 'src/modules/messaging/message-import-manager/services/messaging-cursor.service';
|
||||
@@ -72,14 +74,16 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingMessageListFetchCronCommand,
|
||||
MessagingMessagesImportCronCommand,
|
||||
MessagingOngoingStaleCronCommand,
|
||||
MessagingRelaunchFailedMessageChannelsCronCommand,
|
||||
MessagingSingleMessageImportCommand,
|
||||
MessagingRelaunchFailedMessageChannelsCommand,
|
||||
MessagingMessageListFetchJob,
|
||||
MessagingMessagesImportJob,
|
||||
MessagingOngoingStaleJob,
|
||||
MessagingRelaunchFailedMessageChannelJob,
|
||||
MessagingMessageListFetchCronJob,
|
||||
MessagingMessagesImportCronJob,
|
||||
MessagingOngoingStaleCronJob,
|
||||
MessagingRelaunchFailedMessageChannelsCronJob,
|
||||
MessagingAddSingleMessageToCacheForImportJob,
|
||||
MessagingMessageImportManagerMessageChannelListener,
|
||||
MessagingCleanCacheJob,
|
||||
@@ -99,6 +103,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingMessageListFetchCronCommand,
|
||||
MessagingMessagesImportCronCommand,
|
||||
MessagingOngoingStaleCronCommand,
|
||||
MessagingRelaunchFailedMessageChannelsCronCommand,
|
||||
],
|
||||
})
|
||||
export class MessagingImportManagerModule {}
|
||||
|
||||
+1
@@ -98,6 +98,7 @@ export class MessagingAccountAuthenticationService {
|
||||
);
|
||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED:
|
||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND:
|
||||
case ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN:
|
||||
await this.messagingMonitoringService.track({
|
||||
eventName: `refresh_token.error.insufficient_permissions`,
|
||||
workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user