diff --git a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts index 8d9655060..45aece6ae 100644 --- a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts +++ b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts @@ -11,5 +11,6 @@ export type ConnectedAccount = { refreshToken: string; accountOwnerId: string; lastSyncHistoryId: string; + authFailedAt: Date | null; messageChannels: MessageChannelConnection; }; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx index bacd86322..3222dc89b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx @@ -9,7 +9,10 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard'; -import { SettingsAccountsSynchronizationStatus } from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; +import { + SettingsAccountsSynchronizationStatus, + SettingsAccountsSynchronizationStatusProps, +} from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; import { SettingsListCard } from '@/settings/components/SettingsListCard'; import { IconGoogleCalendar } from '@/ui/display/icon/components/IconGoogleCalendar'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; @@ -35,7 +38,11 @@ export const SettingsAccountsCalendarChannelsListCard = () => { }); const { records: calendarChannels, loading: calendarChannelsLoading } = - useFindManyRecords({ + useFindManyRecords< + CalendarChannel & { + connectedAccount: ConnectedAccount; + } + >({ objectNameSingular: CoreObjectNameSingular.CalendarChannel, skip: !accounts.length, filter: { @@ -49,9 +56,22 @@ export const SettingsAccountsCalendarChannelsListCard = () => { return ; } + const calendarChannelsWithSyncStatus: (CalendarChannel & { + connectedAccount: ConnectedAccount; + } & SettingsAccountsSynchronizationStatusProps)[] = calendarChannels.map( + (calendarChannel) => ({ + ...calendarChannel, + syncStatus: calendarChannel.connectedAccount?.authFailedAt + ? 'failed' + : calendarChannel.isSyncEnabled + ? 'synced' + : 'notSynced', + }), + ); + return ( calendarChannel.handle} isLoading={accountsLoading || calendarChannelsLoading} onRowClick={(calendarChannel) => @@ -61,7 +81,7 @@ export const SettingsAccountsCalendarChannelsListCard = () => { RowRightComponent={({ item: calendarChannel }) => ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx index e2a6fbaac..1f215f81f 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx @@ -1,4 +1,5 @@ import { useNavigate } from 'react-router-dom'; +import styled from '@emotion/styled'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard'; @@ -6,9 +7,16 @@ import { SettingsAccountsRowDropdownMenu } from '@/settings/accounts/components/ import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; import { IconGoogle } from '@/ui/display/icon/components/IconGoogle'; +import { Status } from '@/ui/display/status/components/Status'; import { SettingsListCard } from '../../components/SettingsListCard'; +const StyledRowRightContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + export const SettingsAccountsConnectedAccountsListCard = ({ accounts, loading, @@ -29,7 +37,12 @@ export const SettingsAccountsConnectedAccountsListCard = ({ isLoading={loading} RowIcon={IconGoogle} RowRightComponent={({ item: account }) => ( - + + {account.authFailedAt && ( + + )} + + )} hasFooter footerButtonLabel="Add account" diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx index 9f891846b..ffdc1deaf 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx @@ -1,13 +1,11 @@ -import { useCallback } from 'react'; import styled from '@emotion/styled'; +import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; import { IconGoogle } from '@/ui/display/icon/components/IconGoogle'; import { Button } from '@/ui/input/button/components/Button'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardHeader } from '@/ui/layout/card/components/CardHeader'; -import { REACT_APP_SERVER_BASE_URL } from '~/config'; -import { useGenerateTransientTokenMutation } from '~/generated/graphql'; const StyledHeader = styled(CardHeader)` align-items: center; @@ -27,18 +25,7 @@ type SettingsAccountsListEmptyStateCardProps = { export const SettingsAccountsListEmptyStateCard = ({ label, }: SettingsAccountsListEmptyStateCardProps) => { - const [generateTransientToken] = useGenerateTransientTokenMutation(); - - const handleGmailLogin = useCallback(async () => { - const authServerUrl = REACT_APP_SERVER_BASE_URL; - - const transientToken = await generateTransientToken(); - - const token = - transientToken.data?.generateTransientToken.transientToken.token; - - window.location.href = `${authServerUrl}/auth/google-gmail?transientToken=${token}`; - }, [generateTransientToken]); + const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth(); return ( @@ -48,7 +35,7 @@ export const SettingsAccountsListEmptyStateCard = ({ Icon={IconGoogle} title="Connect with Google" variant="secondary" - onClick={handleGmailLogin} + onClick={triggerGoogleApisOAuth} /> diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsListCard.tsx index 1dfea9356..3e18fd05b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsListCard.tsx @@ -10,7 +10,10 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard'; -import { SettingsAccountsSynchronizationStatus } from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; +import { + SettingsAccountsSynchronizationStatus, + SettingsAccountsSynchronizationStatusProps, +} from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; import { SettingsListCard } from '@/settings/components/SettingsListCard'; import { IconGmail } from '@/ui/display/icon/components/IconGmail'; @@ -35,7 +38,11 @@ export const SettingsAccountsMessageChannelsListCard = () => { }); const { records: messageChannels, loading: messageChannelsLoading } = - useFindManyRecords({ + useFindManyRecords< + MessageChannel & { + connectedAccount: ConnectedAccount; + } + >({ objectNameSingular: CoreObjectNameSingular.MessageChannel, filter: { connectedAccountId: { @@ -44,10 +51,14 @@ export const SettingsAccountsMessageChannelsListCard = () => { }, }); - const messageChannelsWithSyncedEmails = messageChannels.map( + const messageChannelsWithSyncedEmails: (MessageChannel & { + connectedAccount: ConnectedAccount; + } & SettingsAccountsSynchronizationStatusProps)[] = messageChannels.map( (messageChannel) => ({ ...messageChannel, - isSynced: true, + syncStatus: messageChannel.connectedAccount?.authFailedAt + ? 'failed' + : 'synced', }), ); @@ -67,7 +78,7 @@ export const SettingsAccountsMessageChannelsListCard = () => { RowRightComponent={({ item: messageChannel }) => ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx index 09ad94c42..0e3812778 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx @@ -1,9 +1,10 @@ import { useNavigate } from 'react-router-dom'; -import { IconDotsVertical, IconMail, IconTrash } from 'twenty-ui'; +import { IconDotsVertical, IconMail, IconRefresh, IconTrash } from 'twenty-ui'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; @@ -12,12 +13,12 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; type SettingsAccountsRowDropdownMenuProps = { - item: Pick; + account: ConnectedAccount; className?: string; }; export const SettingsAccountsRowDropdownMenu = ({ - item: account, + account, className, }: SettingsAccountsRowDropdownMenuProps) => { const dropdownId = `settings-account-row-${account.id}`; @@ -29,6 +30,8 @@ export const SettingsAccountsRowDropdownMenu = ({ objectNameSingular: CoreObjectNameSingular.ConnectedAccount, }); + const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth(); + return ( + {account.authFailedAt && ( + { + triggerGoogleApisOAuth(); + closeDropdown(); + }} + /> + )} ( ); diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts new file mode 100644 index 000000000..5d1f1b196 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; + +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { useGenerateTransientTokenMutation } from '~/generated/graphql'; + +export const useTriggerGoogleApisOAuth = () => { + const [generateTransientToken] = useGenerateTransientTokenMutation(); + + const triggerGoogleApisOAuth = useCallback(async () => { + const authServerUrl = REACT_APP_SERVER_BASE_URL; + + const transientToken = await generateTransientToken(); + + const token = + transientToken.data?.generateTransientToken.transientToken.token; + + window.location.href = `${authServerUrl}/auth/google-gmail?transientToken=${token}`; + }, [generateTransientToken]); + + return { triggerGoogleApisOAuth }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 9ae6f4c70..dfafbb583 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -47,11 +47,10 @@ export class GoogleAPIsAuthController { throw new Error('Workspace not found'); } - await this.googleAPIsService.saveConnectedAccount({ + await this.googleAPIsService.saveOrUpdateConnectedAccount({ handle: email, workspaceMemberId: workspaceMemberId, workspaceId: workspaceId, - provider: 'google', accessToken, refreshToken, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-gmail-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-gmail-auth.controller.ts index 03df019d6..e4d40104b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-gmail-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-gmail-auth.controller.ts @@ -12,7 +12,7 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm @Controller('auth/google-gmail') export class GoogleGmailAuthController { constructor( - private readonly googleGmailService: GoogleAPIsService, + private readonly googleAPIsService: GoogleAPIsService, private readonly tokenService: TokenService, private readonly environmentService: EnvironmentService, ) {} @@ -47,11 +47,10 @@ export class GoogleGmailAuthController { throw new Error('Workspace not found'); } - await this.googleGmailService.saveConnectedAccount({ + await this.googleAPIsService.saveOrUpdateConnectedAccount({ handle: email, workspaceMemberId: workspaceMemberId, workspaceId: workspaceId, - provider: 'gmail', accessToken, refreshToken, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/save-connected-account.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/save-connected-account.ts index f4f5f389a..68d809c7f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/save-connected-account.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/save-connected-account.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; @ArgsType() -export class SaveConnectedAccountInput { +export class SaveOrUpdateConnectedAccountInput { @Field(() => String) @IsNotEmpty() @IsString() @@ -19,11 +19,6 @@ export class SaveConnectedAccountInput { @IsString() workspaceId: string; - @Field(() => String) - @IsNotEmpty() - @IsString() - provider: string; - @Field(() => String) @IsNotEmpty() @IsString() diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/update-connected-account.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/update-connected-account.ts new file mode 100644 index 000000000..27fa32421 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/update-connected-account.ts @@ -0,0 +1,26 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class UpdateConnectedAccountInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + workspaceId: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + accessToken: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + refreshToken: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + connectedAccountId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-gmail-provider-enabled.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-gmail-provider-enabled.guard.ts deleted file mode 100644 index 2956601f2..000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-gmail-provider-enabled.guard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, CanActivate, NotFoundException } from '@nestjs/common'; - -import { Observable } from 'rxjs'; - -import { GoogleAPIsStrategy } from 'src/engine/core-modules/auth/strategies/google-apis.auth.strategy'; -import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; - -@Injectable() -export class GoogleGmailProviderEnabledGuard implements CanActivate { - constructor(private readonly environmentService: EnvironmentService) {} - - canActivate(): boolean | Promise | Observable { - if (!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { - throw new NotFoundException('Gmail auth is not enabled'); - } - - new GoogleAPIsStrategy(this.environmentService); - - return true; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index 18c61d069..78f7d2875 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { v4 } from 'uuid'; @@ -6,7 +6,7 @@ import { Repository } from 'typeorm'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; -import { SaveConnectedAccountInput } from 'src/engine/core-modules/auth/dto/save-connected-account'; +import { SaveOrUpdateConnectedAccountInput } from 'src/engine/core-modules/auth/dto/save-connected-account'; import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service'; import { @@ -26,6 +26,7 @@ import { GmailFullSyncV2Job, GmailFullSyncV2JobData, } from 'src/modules/messaging/jobs/gmail-full-sync-v2.job'; +import { UpdateConnectedAccountInput } from 'src/engine/core-modules/auth/dto/update-connected-account'; @Injectable() export class GoogleAPIsService { @@ -43,8 +44,37 @@ export class GoogleAPIsService { providerName = 'google'; + async saveOrUpdateConnectedAccount( + saveOrUpdateConnectedAccountInput: SaveOrUpdateConnectedAccountInput, + ) { + const { handle, workspaceId, workspaceMemberId } = + saveOrUpdateConnectedAccountInput; + + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSourceMetadata); + + const connectedAccount = await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "handle" = $1 AND "provider" = $2 AND "accountOwnerId" = $3`, + [handle, this.providerName, workspaceMemberId], + ); + + if (connectedAccount.length > 0) { + await this.updateConnectedAccount({ + ...saveOrUpdateConnectedAccountInput, + connectedAccountId: connectedAccount[0].id, + }); + } else { + await this.saveConnectedAccount(saveOrUpdateConnectedAccountInput); + } + } + async saveConnectedAccount( - saveConnectedAccountInput: SaveConnectedAccountInput, + saveConnectedAccountInput: SaveOrUpdateConnectedAccountInput, ) { const { handle, @@ -62,15 +92,6 @@ export class GoogleAPIsService { const workspaceDataSource = await this.typeORMService.connectToDataSource(dataSourceMetadata); - const connectedAccount = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "handle" = $1 AND "provider" = $2 AND "accountOwnerId" = $3`, - [handle, this.providerName, workspaceMemberId], - ); - - if (connectedAccount.length > 0) { - throw new ConflictException('Connected account already exists'); - } - const connectedAccountId = v4(); const IsCalendarEnabled = await this.featureFlagRepository.findOneBy({ @@ -79,12 +100,6 @@ export class GoogleAPIsService { value: true, }); - const isFullSyncV2Enabled = await this.featureFlagRepository.findOneBy({ - workspaceId, - key: FeatureFlagKeys.IsFullSyncV2Enabled, - value: true, - }); - await workspaceDataSource?.transaction(async (manager) => { await manager.query( `INSERT INTO ${dataSourceMetadata.schema}."connectedAccount" ("id", "handle", "provider", "accessToken", "refreshToken", "accountOwnerId") VALUES ($1, $2, $3, $4, $5, $6)`, @@ -117,34 +132,78 @@ export class GoogleAPIsService { }); if (this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { - if (isFullSyncV2Enabled) { - await this.messageQueueService.add( - GmailFullSyncV2Job.name, - { - workspaceId, - connectedAccountId, - }, - ); - } else { - await this.messageQueueService.add( - GmailFullSyncJob.name, - { - workspaceId, - connectedAccountId, - }, - { - retryLimit: 2, - }, - ); - } + await this.enqueueGmailFullSyncJob(workspaceId, connectedAccountId); } if ( this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') && IsCalendarEnabled ) { - await this.calendarQueueService.add( - GoogleCalendarFullSyncJob.name, + await this.enqueueGoogleCalendarFullSyncJob( + workspaceId, + connectedAccountId, + ); + } + + return; + } + + async updateConnectedAccount( + updateConnectedAccountInput: UpdateConnectedAccountInput, + ) { + const { workspaceId, accessToken, refreshToken, connectedAccountId } = + updateConnectedAccountInput; + + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSourceMetadata); + + await workspaceDataSource?.transaction(async (manager) => { + await manager.query( + `UPDATE ${dataSourceMetadata.schema}."connectedAccount" SET "accessToken" = $1, "refreshToken" = $2, "authFailedAt" = NULL WHERE "id" = $3`, + [accessToken, refreshToken, connectedAccountId], + ); + }); + + if (this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { + await this.enqueueGmailFullSyncJob(workspaceId, connectedAccountId); + } + + if (this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')) { + await this.enqueueGoogleCalendarFullSyncJob( + workspaceId, + connectedAccountId, + ); + } + + return; + } + + async enqueueGmailFullSyncJob( + workspaceId: string, + connectedAccountId: string, + ) { + const isFullSyncV2Enabled = await this.featureFlagRepository.findOneBy({ + workspaceId, + key: FeatureFlagKeys.IsFullSyncV2Enabled, + value: true, + }); + + if (isFullSyncV2Enabled) { + await this.messageQueueService.add( + GmailFullSyncV2Job.name, + { + workspaceId, + connectedAccountId, + }, + ); + } else { + await this.messageQueueService.add( + GmailFullSyncJob.name, { workspaceId, connectedAccountId, @@ -154,7 +213,21 @@ export class GoogleAPIsService { }, ); } + } - return; + async enqueueGoogleCalendarFullSyncJob( + workspaceId: string, + connectedAccountId: string, + ) { + await this.calendarQueueService.add( + GoogleCalendarFullSyncJob.name, + { + workspaceId, + connectedAccountId, + }, + { + retryLimit: 2, + }, + ); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 894a50021..051f672e3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -132,6 +132,7 @@ export const connectedAccountStandardFieldIds = { refreshToken: '20202020-532d-48bd-80a5-c4be6e7f6e49', accountOwner: '20202020-3517-4896-afac-b1d0aa362af6', lastSyncHistoryId: '20202020-115c-4a87-b50f-ac4367a971b9', + authFailedAt: '20202020-d268-4c6b-baff-400d402b430a', messageChannels: '20202020-24f7-4362-8468-042204d1e445', calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977', }; diff --git a/packages/twenty-server/src/modules/connected-account/repositories/connected-account.repository.ts b/packages/twenty-server/src/modules/connected-account/repositories/connected-account.repository.ts index 5a46a1a8e..268e46cce 100644 --- a/packages/twenty-server/src/modules/connected-account/repositories/connected-account.repository.ts +++ b/packages/twenty-server/src/modules/connected-account/repositories/connected-account.repository.ts @@ -150,4 +150,20 @@ export class ConnectedAccountRepository { transactionManager, ); } + + public async updateAuthFailedAt( + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."connectedAccount" SET "authFailedAt" = NOW() WHERE "id" = $1`, + [connectedAccountId], + workspaceId, + transactionManager, + ); + } } diff --git a/packages/twenty-server/src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service.ts b/packages/twenty-server/src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service.ts index 284f5b185..ef1f3bb2f 100644 --- a/packages/twenty-server/src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service.ts @@ -38,7 +38,11 @@ export class GoogleAPIRefreshAccessTokenService { ); } - const accessToken = await this.refreshAccessToken(refreshToken); + const accessToken = await this.refreshAccessToken( + refreshToken, + connectedAccountId, + workspaceId, + ); await this.connectedAccountRepository.updateAccessToken( accessToken, @@ -47,22 +51,36 @@ export class GoogleAPIRefreshAccessTokenService { ); } - async refreshAccessToken(refreshToken: string): Promise { - const response = await axios.post( - 'https://oauth2.googleapis.com/token', - { - client_id: this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'), - client_secret: this.environmentService.get('AUTH_GOOGLE_CLIENT_SECRET'), - refresh_token: refreshToken, - grant_type: 'refresh_token', - }, - { - headers: { - 'Content-Type': 'application/json', + async refreshAccessToken( + refreshToken: string, + connectedAccountId: string, + workspaceId: string, + ): Promise { + try { + const response = await axios.post( + 'https://oauth2.googleapis.com/token', + { + client_id: this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'), + client_secret: this.environmentService.get( + 'AUTH_GOOGLE_CLIENT_SECRET', + ), + refresh_token: refreshToken, + grant_type: 'refresh_token', }, - }, - ); + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); - return response.data.access_token; + return response.data.access_token; + } catch (error) { + await this.connectedAccountRepository.updateAuthFailedAt( + connectedAccountId, + workspaceId, + ); + throw new Error(`Error refreshing access token: ${error.message}`); + } } } diff --git a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.object-metadata.ts b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.object-metadata.ts index 7c922b612..2100d6d2b 100644 --- a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.object-metadata.ts +++ b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.object-metadata.ts @@ -8,6 +8,7 @@ import { connectedAccountStandardFieldIds } from 'src/engine/workspace-manager/w import { standardObjectIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator'; import { Gate } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/gate.decorator'; +import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator'; import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator'; import { ObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/object-metadata.decorator'; import { RelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/relation-metadata.decorator'; @@ -81,6 +82,16 @@ export class ConnectedAccountObjectMetadata extends BaseObjectMetadata { }) lastSyncHistoryId: string; + @FieldMetadata({ + standardId: connectedAccountStandardFieldIds.authFailedAt, + type: FieldMetadataType.DATE_TIME, + label: 'Auth failed at', + description: 'Auth failed at', + icon: 'IconX', + }) + @IsNullable() + authFailedAt: Date; + @FieldMetadata({ standardId: connectedAccountStandardFieldIds.messageChannels, type: FieldMetadataType.RELATION,