diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index f20528d23d1d..37c2ca244113 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -45,6 +45,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKeys.IsMessagingAliasFetchingEnabled, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKeys.IsGoogleCalendarSyncV2Enabled, workspaceId: workspaceId, 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 da56c086e1e5..0715d31220a3 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 @@ -9,14 +9,14 @@ import { import { Response } from 'express'; -import { GoogleAPIsProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-apis-provider-enabled.guard'; -import { GoogleAPIsOauthGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth.guard'; -import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/strategies/google-apis.auth.strategy'; +import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context'; +import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard'; +import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; @Controller('auth/google-apis') export class GoogleAPIsAuthController { @@ -29,14 +29,14 @@ export class GoogleAPIsAuthController { ) {} @Get() - @UseGuards(GoogleAPIsProviderEnabledGuard, GoogleAPIsOauthGuard) + @UseGuards(GoogleAPIsOauthRequestCodeGuard) async googleAuth() { // As this method is protected by Google Auth guard, it will trigger Google SSO flow return; } @Get('get-access-token') - @UseGuards(GoogleAPIsProviderEnabledGuard, GoogleAPIsOauthGuard) + @UseGuards(GoogleAPIsOauthExchangeCodeForTokenGuard) async googleAuthGetAccessToken( @Req() req: GoogleAPIsRequest, @Res() res: Response, @@ -44,7 +44,7 @@ export class GoogleAPIsAuthController { const { user } = req; const { - email, + emails, accessToken, refreshToken, transientToken, @@ -68,6 +68,8 @@ export class GoogleAPIsAuthController { throw new Error('Workspace not found'); } + const handle = emails[0].value; + const googleAPIsServiceInstance = await this.loadServiceWithWorkspaceContext.load( this.googleAPIsService, @@ -75,7 +77,7 @@ export class GoogleAPIsAuthController { ); await googleAPIsServiceInstance.refreshGoogleRefreshToken({ - handle: email, + handle, workspaceMemberId: workspaceMemberId, workspaceId: workspaceId, accessToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts new file mode 100644 index 000000000000..56780ecbcc20 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -0,0 +1,74 @@ +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { + GoogleAPIScopeConfig, + GoogleAPIsOauthExchangeCodeForTokenStrategy, +} from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; + +@Injectable() +export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( + 'google-apis', +) { + constructor( + private readonly environmentService: EnvironmentService, + private readonly tokenService: TokenService, + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) { + super(); + } + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const state = JSON.parse(request.query.state); + + if ( + !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && + !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') + ) { + throw new NotFoundException('Google apis auth is not enabled'); + } + + const { workspaceId } = await this.tokenService.verifyTransientToken( + state.transientToken, + ); + + const scopeConfig: GoogleAPIScopeConfig = { + isMessagingAliasFetchingEnabled: + !!(await this.featureFlagRepository.findOneBy({ + workspaceId, + key: FeatureFlagKeys.IsMessagingAliasFetchingEnabled, + value: true, + })), + }; + + new GoogleAPIsOauthExchangeCodeForTokenStrategy( + this.environmentService, + scopeConfig, + ); + + setRequestExtraParams(request, { + transientToken: state.transientToken, + redirectLocation: state.redirectLocation, + calendarVisibility: state.calendarVisibility, + messageVisibility: state.messageVisibility, + }); + + return (await super.canActivate(context)) as boolean; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts new file mode 100644 index 000000000000..d34ea86180b8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -0,0 +1,72 @@ +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { GoogleAPIScopeConfig } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy'; +import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; + +@Injectable() +export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { + constructor( + private readonly environmentService: EnvironmentService, + private readonly tokenService: TokenService, + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) { + super({ + prompt: 'select_account', + }); + } + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + + if ( + !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && + !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') + ) { + throw new NotFoundException('Google apis auth is not enabled'); + } + + const { workspaceId } = await this.tokenService.verifyTransientToken( + request.query.transientToken, + ); + + const scopeConfig: GoogleAPIScopeConfig = { + isMessagingAliasFetchingEnabled: + !!(await this.featureFlagRepository.findOneBy({ + workspaceId, + key: FeatureFlagKeys.IsMessagingAliasFetchingEnabled, + value: true, + })), + }; + + new GoogleAPIsOauthRequestCodeStrategy( + this.environmentService, + scopeConfig, + ); + setRequestExtraParams(request, { + transientToken: request.query.transientToken, + redirectLocation: request.query.redirectLocation, + calendarVisibility: request.query.calendarVisibility, + messageVisibility: request.query.messageVisibility, + }); + + const activate = (await super.canActivate(context)) as boolean; + + return activate; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth.guard.ts deleted file mode 100644 index a1214404fda4..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth.guard.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ExecutionContext, Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class GoogleAPIsOauthGuard extends AuthGuard('google-apis') { - constructor() { - super({ - prompt: 'select_account', - }); - } - - async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); - const transientToken = request.query.transientToken; - const redirectLocation = request.query.redirectLocation; - const calendarVisibility = request.query.calendarVisibility; - const messageVisibility = request.query.messageVisibility; - - if (transientToken && typeof transientToken === 'string') { - request.params.transientToken = transientToken; - } - - if (redirectLocation && typeof redirectLocation === 'string') { - request.params.redirectLocation = redirectLocation; - } - - if (calendarVisibility && typeof calendarVisibility === 'string') { - request.params.calendarVisibility = calendarVisibility; - } - - if (messageVisibility && typeof messageVisibility === 'string') { - request.params.messageVisibility = messageVisibility; - } - - const activate = (await super.canActivate(context)) as boolean; - - return activate; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-provider-enabled.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-provider-enabled.guard.ts deleted file mode 100644 index bbd094be4803..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-provider-enabled.guard.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable, CanActivate, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; - -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; -import { - GoogleAPIScopeConfig, - GoogleAPIsStrategy, -} from 'src/engine/core-modules/auth/strategies/google-apis.auth.strategy'; -import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; -import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; - -@Injectable() -export class GoogleAPIsProviderEnabledGuard implements CanActivate { - constructor( - private readonly environmentService: EnvironmentService, - private readonly tokenService: TokenService, - @InjectRepository(FeatureFlagEntity, 'core') - private readonly featureFlagRepository: Repository, - ) {} - - async canActivate(): Promise { - if ( - !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && - !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') - ) { - throw new NotFoundException('Google apis auth is not enabled'); - } - - const scopeConfig: GoogleAPIScopeConfig = { - isCalendarEnabled: !!this.environmentService.get( - 'MESSAGING_PROVIDER_GMAIL_ENABLED', - ), - }; - - new GoogleAPIsStrategy(this.environmentService, scopeConfig); - - return true; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts new file mode 100644 index 000000000000..99bd05cab01f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts @@ -0,0 +1,41 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; + +import { Strategy } from 'passport-google-oauth20'; + +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; + +export type GoogleAPIScopeConfig = { + isCalendarEnabled?: boolean; + isMessagingAliasFetchingEnabled?: boolean; +}; + +@Injectable() +export class GoogleAPIsOauthCommonStrategy extends PassportStrategy( + Strategy, + 'google-apis', +) { + constructor( + environmentService: EnvironmentService, + scopeConfig: GoogleAPIScopeConfig, + ) { + const scopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.events', + ]; + + if (scopeConfig?.isMessagingAliasFetchingEnabled) { + scopes.push('https://www.googleapis.com/auth/profile.emails.read'); + } + + super({ + clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'), + clientSecret: environmentService.get('AUTH_GOOGLE_CLIENT_SECRET'), + callbackURL: environmentService.get('AUTH_GOOGLE_APIS_CALLBACK_URL'), + scope: scopes, + passReqToCallback: true, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts new file mode 100644 index 000000000000..047a7f55fa01 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; + +import { VerifyCallback } from 'passport-google-oauth20'; + +import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; + +export type GoogleAPIScopeConfig = { + isCalendarEnabled?: boolean; + isMessagingAliasFetchingEnabled?: boolean; +}; + +@Injectable() +export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauthCommonStrategy { + constructor( + environmentService: EnvironmentService, + scopeConfig: GoogleAPIScopeConfig, + ) { + super(environmentService, scopeConfig); + } + + async validate( + request: GoogleAPIsRequest, + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { name, emails, photos } = profile; + + const state = + typeof request.query.state === 'string' + ? JSON.parse(request.query.state) + : undefined; + + const user: GoogleAPIsRequest['user'] = { + emails, + firstName: name.givenName, + lastName: name.familyName, + picture: photos?.[0]?.value, + accessToken, + refreshToken, + transientToken: state.transientToken, + redirectLocation: state.redirectLocation, + calendarVisibility: state.calendarVisibility, + messageVisibility: state.messageVisibility, + }; + + done(null, user); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts new file mode 100644 index 000000000000..128ba607cd45 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; + +import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; + +export type GoogleAPIScopeConfig = { + isCalendarEnabled?: boolean; + isMessagingAliasFetchingEnabled?: boolean; +}; + +@Injectable() +export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStrategy { + constructor( + environmentService: EnvironmentService, + scopeConfig: GoogleAPIScopeConfig, + ) { + super(environmentService, scopeConfig); + } + + authenticate(req: any, options: any) { + options = { + ...options, + accessType: 'offline', + prompt: 'consent', + state: JSON.stringify({ + transientToken: req.params.transientToken, + redirectLocation: req.params.redirectLocation, + calendarVisibility: req.params.calendarVisibility, + messageVisibility: req.params.messageVisibility, + }), + }; + + return super.authenticate(req, options); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts deleted file mode 100644 index f4e55d5c4298..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { PassportStrategy } from '@nestjs/passport'; -import { Injectable } from '@nestjs/common'; - -import { Strategy, VerifyCallback } from 'passport-google-oauth20'; -import { Request } from 'express'; - -import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; -import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity'; -import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; - -export type GoogleAPIsRequest = Omit< - Request, - 'user' | 'workspace' | 'cacheVersion' -> & { - user: { - firstName?: string | null; - lastName?: string | null; - email: string; - picture: string | null; - workspaceInviteHash?: string; - accessToken: string; - refreshToken: string; - transientToken: string; - redirectLocation?: string; - calendarVisibility?: CalendarChannelVisibility; - messageVisibility?: MessageChannelVisibility; - }; -}; - -export type GoogleAPIScopeConfig = { - isCalendarEnabled?: boolean; -}; - -@Injectable() -export class GoogleAPIsStrategy extends PassportStrategy( - Strategy, - 'google-apis', -) { - constructor( - environmentService: EnvironmentService, - scopeConfig: GoogleAPIScopeConfig, - ) { - const scope = ['email', 'profile']; - - if (environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { - scope.push('https://www.googleapis.com/auth/gmail.readonly'); - } - - if ( - environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') && - scopeConfig?.isCalendarEnabled - ) { - scope.push('https://www.googleapis.com/auth/calendar.events'); - } - - super({ - clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'), - clientSecret: environmentService.get('AUTH_GOOGLE_CLIENT_SECRET'), - callbackURL: environmentService.get('AUTH_GOOGLE_APIS_CALLBACK_URL'), - scope, - passReqToCallback: true, - }); - } - - authenticate(req: any, options: any) { - options = { - ...options, - accessType: 'offline', - prompt: 'consent', - state: JSON.stringify({ - transientToken: req.params.transientToken, - redirectLocation: req.params.redirectLocation, - calendarVisibility: req.params.calendarVisibility, - messageVisibility: req.params.messageVisibility, - }), - }; - - return super.authenticate(req, options); - } - - async validate( - request: GoogleAPIsRequest, - accessToken: string, - refreshToken: string, - profile: any, - done: VerifyCallback, - ): Promise { - const { name, emails, photos } = profile; - - const state = - typeof request.query.state === 'string' - ? JSON.parse(request.query.state) - : undefined; - - const user: GoogleAPIsRequest['user'] = { - email: emails[0].value, - firstName: name.givenName, - lastName: name.familyName, - picture: photos?.[0]?.value, - accessToken, - refreshToken, - transientToken: state.transientToken, - redirectLocation: state.redirectLocation, - calendarVisibility: state.calendarVisibility, - messageVisibility: state.messageVisibility, - }; - - done(null, user); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/google-api-request.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/google-api-request.type.ts new file mode 100644 index 000000000000..276de7941e59 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/google-api-request.type.ts @@ -0,0 +1,23 @@ +import { Request } from 'express'; + +import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity'; +import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; + +export type GoogleAPIsRequest = Omit< + Request, + 'user' | 'workspace' | 'cacheVersion' +> & { + user: { + firstName?: string | null; + lastName?: string | null; + emails: { value: string }[]; + picture: string | null; + workspaceInviteHash?: string; + accessToken: string; + refreshToken: string; + transientToken: string; + redirectLocation?: string; + calendarVisibility?: CalendarChannelVisibility; + messageVisibility?: MessageChannelVisibility; + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/google-apis-set-request-extra-params.util.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/google-apis-set-request-extra-params.util.spec.ts new file mode 100644 index 000000000000..be7b92c0c65a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/google-apis-set-request-extra-params.util.spec.ts @@ -0,0 +1,40 @@ +import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; +import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; +import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity'; +import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; + +describe('googleApisSetRequestExtraParams', () => { + it('should set request extra params', () => { + const request = { + params: {}, + } as GoogleAPIsRequest; + + setRequestExtraParams(request, { + transientToken: 'abc', + redirectLocation: '/test', + calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, + messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, + }); + + expect(request.params).toEqual({ + transientToken: 'abc', + redirectLocation: '/test', + calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, + messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, + }); + }); + + it('should throw error if transientToken is not provided', () => { + const request = { + params: {}, + } as GoogleAPIsRequest; + + expect(() => { + setRequestExtraParams(request, { + redirectLocation: '/test', + calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, + messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, + }); + }).toThrow('transientToken is required'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts new file mode 100644 index 000000000000..668426c23bc1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts @@ -0,0 +1,38 @@ +import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; + +type GoogleAPIsRequestExtraParams = { + transientToken?: string; + redirectLocation?: string; + calendarVisibility?: string; + messageVisibility?: string; +}; + +export const setRequestExtraParams = ( + request: GoogleAPIsRequest, + params: GoogleAPIsRequestExtraParams, +): void => { + const { + transientToken, + redirectLocation, + calendarVisibility, + messageVisibility, + } = params; + + if (!transientToken) { + throw new Error('transientToken is required'); + } + + request.params.transientToken = transientToken; + + if (redirectLocation) { + request.params.redirectLocation = redirectLocation; + } + + if (calendarVisibility) { + request.params.calendarVisibility = calendarVisibility; + } + + if (messageVisibility) { + request.params.messageVisibility = messageVisibility; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index 775604f7d295..4e71805b9c93 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -11,9 +11,9 @@ import { import { Response } from 'express'; import { - BillingService, + BillingWorkspaceService, WebhookEvent, -} from 'src/engine/core-modules/billing/billing.service'; +} from 'src/engine/core-modules/billing/billing.workspace-service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; @Controller('billing') @@ -22,7 +22,7 @@ export class BillingController { constructor( private readonly stripeService: StripeService, - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, ) {} @Post('/webhooks') @@ -42,7 +42,7 @@ export class BillingController { ); if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) { - await this.billingService.handleUnpaidInvoices(event.data); + await this.billingWorkspaceService.handleUnpaidInvoices(event.data); } if ( @@ -58,7 +58,7 @@ export class BillingController { return; } - await this.billingService.upsertBillingSubscription( + await this.billingWorkspaceService.upsertBillingSubscription( workspaceId, event.data, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 491faaec7e29..a4462607a737 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -11,6 +11,7 @@ import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolve import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; @Module({ imports: [ @@ -27,7 +28,12 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- ), ], controllers: [BillingController], - providers: [BillingService, BillingResolver, BillingWorkspaceMemberListener], - exports: [BillingService], + providers: [ + BillingService, + BillingWorkspaceService, + BillingResolver, + BillingWorkspaceMemberListener, + ], + exports: [BillingService, BillingWorkspaceService], }) export class BillingModule {} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 8766c4adc37e..40c90fbc2cea 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -3,8 +3,8 @@ import { UseGuards } from '@nestjs/common'; import { AvailableProduct, - BillingService, -} from 'src/engine/core-modules/billing/billing.service'; + BillingWorkspaceService, +} from 'src/engine/core-modules/billing/billing.workspace-service'; import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input'; import { assert } from 'src/utils/assert'; import { ProductPricesEntity } from 'src/engine/core-modules/billing/dto/product-prices.entity'; @@ -18,11 +18,14 @@ import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update- @Resolver() export class BillingResolver { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingWorkspaceService: BillingWorkspaceService, + ) {} @Query(() => ProductPricesEntity) async getProductPrices(@Args() { product }: ProductInput) { - const stripeProductId = this.billingService.getProductStripeId(product); + const stripeProductId = + this.billingWorkspaceService.getProductStripeId(product); assert( stripeProductId, @@ -32,7 +35,7 @@ export class BillingResolver { ); const productPrices = - await this.billingService.getProductPrices(stripeProductId); + await this.billingWorkspaceService.getProductPrices(stripeProductId); return { totalNumberOfPrices: productPrices.length, @@ -47,7 +50,7 @@ export class BillingResolver { @Args() { returnUrlPath }: BillingSessionInput, ) { return { - url: await this.billingService.computeBillingPortalSessionURL( + url: await this.billingWorkspaceService.computeBillingPortalSessionURL( user.defaultWorkspaceId, returnUrlPath, ), @@ -60,7 +63,7 @@ export class BillingResolver { @AuthUser() user: User, @Args() { recurringInterval, successUrlPath }: CheckoutSessionInput, ) { - const stripeProductId = this.billingService.getProductStripeId( + const stripeProductId = this.billingWorkspaceService.getProductStripeId( AvailableProduct.BasePlan, ); @@ -70,7 +73,7 @@ export class BillingResolver { ); const productPrices = - await this.billingService.getProductPrices(stripeProductId); + await this.billingWorkspaceService.getProductPrices(stripeProductId); const stripePriceId = productPrices.filter( (price) => price.recurringInterval === recurringInterval, @@ -82,7 +85,7 @@ export class BillingResolver { ); return { - url: await this.billingService.computeCheckoutSessionURL( + url: await this.billingWorkspaceService.computeCheckoutSessionURL( user, stripePriceId, successUrlPath, @@ -93,7 +96,7 @@ export class BillingResolver { @Mutation(() => UpdateBillingEntity) @UseGuards(JwtAuthGuard) async updateBillingSubscription(@AuthUser() user: User) { - await this.billingService.updateBillingSubscription(user); + await this.billingWorkspaceService.updateBillingSubscription(user); return { success: true }; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts index 8a391b06c61c..1fafb12cb3ad 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.service.ts @@ -1,51 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import Stripe from 'stripe'; -import { In, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { BillingSubscription, - SubscriptionInterval, SubscriptionStatus, } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { assert } from 'src/utils/assert'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { - FeatureFlagEntity, - FeatureFlagKeys, -} from 'src/engine/core-modules/feature-flag/feature-flag.entity'; - -export enum AvailableProduct { - BasePlan = 'base-plan', -} - -export enum WebhookEvent { - CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', - CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', - CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', - SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', -} @Injectable() export class BillingService { protected readonly logger = new Logger(BillingService.name); constructor( - private readonly stripeService: StripeService, - private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, @InjectRepository(BillingSubscription, 'core') - private readonly billingSubscriptionRepository: Repository, - @InjectRepository(FeatureFlagEntity, 'core') - private readonly featureFlagRepository: Repository, - @InjectRepository(BillingSubscriptionItem, 'core') - private readonly billingSubscriptionItemRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, ) {} @@ -68,283 +38,4 @@ export class BillingService { }) ).map((workspace) => workspace.id); } - - async isBillingEnabledForWorkspace(workspaceId: string) { - const isFreeAccessEnabled = await this.featureFlagRepository.findOneBy({ - workspaceId, - key: FeatureFlagKeys.IsFreeAccessEnabled, - value: true, - }); - - return ( - !isFreeAccessEnabled && this.environmentService.get('IS_BILLING_ENABLED') - ); - } - - getProductStripeId(product: AvailableProduct) { - if (product === AvailableProduct.BasePlan) { - return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID'); - } - } - - async getProductPrices(stripeProductId: string) { - const productPrices = - await this.stripeService.getProductPrices(stripeProductId); - - return this.formatProductPrices(productPrices.data); - } - - formatProductPrices(prices: Stripe.Price[]) { - const result: Record = {}; - - prices.forEach((item) => { - const interval = item.recurring?.interval; - - if (!interval || !item.unit_amount) { - return; - } - if ( - !result[interval] || - item.created > (result[interval]?.created || 0) - ) { - result[interval] = { - unitAmount: item.unit_amount, - recurringInterval: interval, - created: item.created, - stripePriceId: item.id, - }; - } - }); - - return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount); - } - - async getCurrentBillingSubscription(criteria: { - workspaceId?: string; - stripeCustomerId?: string; - }) { - const notCanceledSubscriptions = - await this.billingSubscriptionRepository.find({ - where: { ...criteria, status: Not(SubscriptionStatus.Canceled) }, - relations: ['billingSubscriptionItems'], - }); - - assert( - notCanceledSubscriptions.length <= 1, - `More than one not canceled subscription for workspace ${criteria.workspaceId}`, - ); - - return notCanceledSubscriptions?.[0]; - } - - async getBillingSubscription(stripeSubscriptionId: string) { - return this.billingSubscriptionRepository.findOneOrFail({ - where: { stripeSubscriptionId }, - }); - } - - async getStripeCustomerId(workspaceId: string) { - const subscriptions = await this.billingSubscriptionRepository.find({ - where: { workspaceId }, - }); - - return subscriptions?.[0]?.stripeCustomerId; - } - - async getBillingSubscriptionItem( - workspaceId: string, - stripeProductId = this.environmentService.get( - 'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID', - ), - ) { - const billingSubscription = await this.getCurrentBillingSubscription({ - workspaceId, - }); - - if (!billingSubscription) { - throw new Error( - `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`, - ); - } - - const billingSubscriptionItem = - billingSubscription.billingSubscriptionItems.filter( - (billingSubscriptionItem) => - billingSubscriptionItem.stripeProductId === stripeProductId, - )?.[0]; - - if (!billingSubscriptionItem) { - throw new Error( - `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`, - ); - } - - return billingSubscriptionItem; - } - - async computeBillingPortalSessionURL( - workspaceId: string, - returnUrlPath?: string, - ) { - const stripeCustomerId = await this.getStripeCustomerId(workspaceId); - - if (!stripeCustomerId) { - return; - } - - const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); - const returnUrl = returnUrlPath - ? frontBaseUrl + returnUrlPath - : frontBaseUrl; - - const session = await this.stripeService.createBillingPortalSession( - stripeCustomerId, - returnUrl, - ); - - assert(session.url, 'Error: missing billingPortal.session.url'); - - return session.url; - } - - async updateBillingSubscription(user: User) { - const billingSubscription = await this.getCurrentBillingSubscription({ - workspaceId: user.defaultWorkspaceId, - }); - const newInterval = - billingSubscription?.interval === SubscriptionInterval.Year - ? SubscriptionInterval.Month - : SubscriptionInterval.Year; - const billingSubscriptionItem = await this.getBillingSubscriptionItem( - user.defaultWorkspaceId, - ); - const stripeProductId = this.getProductStripeId(AvailableProduct.BasePlan); - - if (!stripeProductId) { - throw new Error('Stripe product id not found for basePlan'); - } - const productPrices = await this.getProductPrices(stripeProductId); - - const stripePriceId = productPrices.filter( - (price) => price.recurringInterval === newInterval, - )?.[0]?.stripePriceId; - - await this.stripeService.updateBillingSubscriptionItem( - billingSubscriptionItem, - stripePriceId, - ); - } - - async computeCheckoutSessionURL( - user: User, - priceId: string, - successUrlPath?: string, - ): Promise { - const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); - const successUrl = successUrlPath - ? frontBaseUrl + successUrlPath - : frontBaseUrl; - - const quantity = - (await this.userWorkspaceService.getWorkspaceMemberCount()) || 1; - - const stripeCustomerId = ( - await this.billingSubscriptionRepository.findOneBy({ - workspaceId: user.defaultWorkspaceId, - }) - )?.stripeCustomerId; - - const session = await this.stripeService.createCheckoutSession( - user, - priceId, - quantity, - successUrl, - frontBaseUrl, - stripeCustomerId, - ); - - assert(session.url, 'Error: missing checkout.session.url'); - - return session.url; - } - - async deleteSubscription(workspaceId: string) { - const subscriptionToCancel = await this.getCurrentBillingSubscription({ - workspaceId, - }); - - if (subscriptionToCancel) { - await this.stripeService.cancelSubscription( - subscriptionToCancel.stripeSubscriptionId, - ); - await this.billingSubscriptionRepository.delete(subscriptionToCancel.id); - } - } - - async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) { - const billingSubscription = await this.getCurrentBillingSubscription({ - stripeCustomerId: data.object.customer as string, - }); - - if (billingSubscription?.status === 'unpaid') { - await this.stripeService.collectLastInvoice( - billingSubscription.stripeSubscriptionId, - ); - } - } - - async upsertBillingSubscription( - workspaceId: string, - data: - | Stripe.CustomerSubscriptionUpdatedEvent.Data - | Stripe.CustomerSubscriptionCreatedEvent.Data - | Stripe.CustomerSubscriptionDeletedEvent.Data, - ) { - const workspace = this.workspaceRepository.find({ - where: { id: workspaceId }, - }); - - if (!workspace) { - return; - } - - await this.billingSubscriptionRepository.upsert( - { - workspaceId: workspaceId, - stripeCustomerId: data.object.customer as string, - stripeSubscriptionId: data.object.id, - status: data.object.status, - interval: data.object.items.data[0].plan.interval, - }, - { - conflictPaths: ['stripeSubscriptionId'], - skipUpdateIfNoValuesChanged: true, - }, - ); - - const billingSubscription = await this.getBillingSubscription( - data.object.id, - ); - - await this.billingSubscriptionItemRepository.upsert( - data.object.items.data.map((item) => { - return { - billingSubscriptionId: billingSubscription.id, - stripeProductId: item.price.product as string, - stripePriceId: item.price.id, - stripeSubscriptionItemId: item.id, - quantity: item.quantity, - }; - }), - { - conflictPaths: ['billingSubscriptionId', 'stripeProductId'], - skipUpdateIfNoValuesChanged: true, - }, - ); - - await this.featureFlagRepository.delete({ - workspaceId, - key: FeatureFlagKeys.IsFreeAccessEnabled, - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.workspace-service.ts new file mode 100644 index 000000000000..d912751ea7e6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.workspace-service.ts @@ -0,0 +1,332 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import Stripe from 'stripe'; +import { Not, Repository } from 'typeorm'; + +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { + BillingSubscription, + SubscriptionInterval, + SubscriptionStatus, +} from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { assert } from 'src/utils/assert'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { BillingService } from 'src/engine/core-modules/billing/billing.service'; + +export enum AvailableProduct { + BasePlan = 'base-plan', +} + +export enum WebhookEvent { + CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', + CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', + CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', + SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', +} + +@Injectable() +export class BillingWorkspaceService { + protected readonly logger = new Logger(BillingService.name); + constructor( + private readonly stripeService: StripeService, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly environmentService: EnvironmentService, + @InjectRepository(BillingSubscription, 'core') + private readonly billingSubscriptionRepository: Repository, + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + @InjectRepository(BillingSubscriptionItem, 'core') + private readonly billingSubscriptionItemRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + ) {} + + async isBillingEnabledForWorkspace(workspaceId: string) { + const isFreeAccessEnabled = await this.featureFlagRepository.findOneBy({ + workspaceId, + key: FeatureFlagKeys.IsFreeAccessEnabled, + value: true, + }); + + return ( + !isFreeAccessEnabled && this.environmentService.get('IS_BILLING_ENABLED') + ); + } + + getProductStripeId(product: AvailableProduct) { + if (product === AvailableProduct.BasePlan) { + return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID'); + } + } + + async getProductPrices(stripeProductId: string) { + const productPrices = + await this.stripeService.getProductPrices(stripeProductId); + + return this.formatProductPrices(productPrices.data); + } + + formatProductPrices(prices: Stripe.Price[]) { + const result: Record = {}; + + prices.forEach((item) => { + const interval = item.recurring?.interval; + + if (!interval || !item.unit_amount) { + return; + } + if ( + !result[interval] || + item.created > (result[interval]?.created || 0) + ) { + result[interval] = { + unitAmount: item.unit_amount, + recurringInterval: interval, + created: item.created, + stripePriceId: item.id, + }; + } + }); + + return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount); + } + + async getCurrentBillingSubscription(criteria: { + workspaceId?: string; + stripeCustomerId?: string; + }) { + const notCanceledSubscriptions = + await this.billingSubscriptionRepository.find({ + where: { ...criteria, status: Not(SubscriptionStatus.Canceled) }, + relations: ['billingSubscriptionItems'], + }); + + assert( + notCanceledSubscriptions.length <= 1, + `More than one not canceled subscription for workspace ${criteria.workspaceId}`, + ); + + return notCanceledSubscriptions?.[0]; + } + + async getBillingSubscription(stripeSubscriptionId: string) { + return this.billingSubscriptionRepository.findOneOrFail({ + where: { stripeSubscriptionId }, + }); + } + + async getStripeCustomerId(workspaceId: string) { + const subscriptions = await this.billingSubscriptionRepository.find({ + where: { workspaceId }, + }); + + return subscriptions?.[0]?.stripeCustomerId; + } + + async getBillingSubscriptionItem( + workspaceId: string, + stripeProductId = this.environmentService.get( + 'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID', + ), + ) { + const billingSubscription = await this.getCurrentBillingSubscription({ + workspaceId, + }); + + if (!billingSubscription) { + throw new Error( + `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`, + ); + } + + const billingSubscriptionItem = + billingSubscription.billingSubscriptionItems.filter( + (billingSubscriptionItem) => + billingSubscriptionItem.stripeProductId === stripeProductId, + )?.[0]; + + if (!billingSubscriptionItem) { + throw new Error( + `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`, + ); + } + + return billingSubscriptionItem; + } + + async computeBillingPortalSessionURL( + workspaceId: string, + returnUrlPath?: string, + ) { + const stripeCustomerId = await this.getStripeCustomerId(workspaceId); + + if (!stripeCustomerId) { + return; + } + + const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); + const returnUrl = returnUrlPath + ? frontBaseUrl + returnUrlPath + : frontBaseUrl; + + const session = await this.stripeService.createBillingPortalSession( + stripeCustomerId, + returnUrl, + ); + + assert(session.url, 'Error: missing billingPortal.session.url'); + + return session.url; + } + + async updateBillingSubscription(user: User) { + const billingSubscription = await this.getCurrentBillingSubscription({ + workspaceId: user.defaultWorkspaceId, + }); + const newInterval = + billingSubscription?.interval === SubscriptionInterval.Year + ? SubscriptionInterval.Month + : SubscriptionInterval.Year; + const billingSubscriptionItem = await this.getBillingSubscriptionItem( + user.defaultWorkspaceId, + ); + const stripeProductId = this.getProductStripeId(AvailableProduct.BasePlan); + + if (!stripeProductId) { + throw new Error('Stripe product id not found for basePlan'); + } + const productPrices = await this.getProductPrices(stripeProductId); + + const stripePriceId = productPrices.filter( + (price) => price.recurringInterval === newInterval, + )?.[0]?.stripePriceId; + + await this.stripeService.updateBillingSubscriptionItem( + billingSubscriptionItem, + stripePriceId, + ); + } + + async computeCheckoutSessionURL( + user: User, + priceId: string, + successUrlPath?: string, + ): Promise { + const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); + const successUrl = successUrlPath + ? frontBaseUrl + successUrlPath + : frontBaseUrl; + + const quantity = + (await this.userWorkspaceService.getWorkspaceMemberCount()) || 1; + + const stripeCustomerId = ( + await this.billingSubscriptionRepository.findOneBy({ + workspaceId: user.defaultWorkspaceId, + }) + )?.stripeCustomerId; + + const session = await this.stripeService.createCheckoutSession( + user, + priceId, + quantity, + successUrl, + frontBaseUrl, + stripeCustomerId, + ); + + assert(session.url, 'Error: missing checkout.session.url'); + + return session.url; + } + + async deleteSubscription(workspaceId: string) { + const subscriptionToCancel = await this.getCurrentBillingSubscription({ + workspaceId, + }); + + if (subscriptionToCancel) { + await this.stripeService.cancelSubscription( + subscriptionToCancel.stripeSubscriptionId, + ); + await this.billingSubscriptionRepository.delete(subscriptionToCancel.id); + } + } + + async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) { + const billingSubscription = await this.getCurrentBillingSubscription({ + stripeCustomerId: data.object.customer as string, + }); + + if (billingSubscription?.status === 'unpaid') { + await this.stripeService.collectLastInvoice( + billingSubscription.stripeSubscriptionId, + ); + } + } + + async upsertBillingSubscription( + workspaceId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, + ) { + const workspace = this.workspaceRepository.find({ + where: { id: workspaceId }, + }); + + if (!workspace) { + return; + } + + await this.billingSubscriptionRepository.upsert( + { + workspaceId: workspaceId, + stripeCustomerId: data.object.customer as string, + stripeSubscriptionId: data.object.id, + status: data.object.status, + interval: data.object.items.data[0].plan.interval, + }, + { + conflictPaths: ['stripeSubscriptionId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + + const billingSubscription = await this.getBillingSubscription( + data.object.id, + ); + + await this.billingSubscriptionItemRepository.upsert( + data.object.items.data.map((item) => { + return { + billingSubscriptionId: billingSubscription.id, + stripeProductId: item.price.product as string, + stripePriceId: item.price.id, + stripeSubscriptionItemId: item.id, + quantity: item.quantity, + }; + }), + { + conflictPaths: ['billingSubscriptionId', 'stripeProductId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + + await this.featureFlagRepository.delete({ + workspaceId, + key: FeatureFlagKeys.IsFreeAccessEnabled, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts index 089d18ba0e05..1bab951e2eb1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts @@ -2,7 +2,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; -import { AvailableProduct } from 'src/engine/core-modules/billing/billing.service'; +import { AvailableProduct } from 'src/engine/core-modules/billing/billing.workspace-service'; @ArgsType() export class ProductInput { diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts index 84388027d3ed..9302c50f8d56 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts @@ -1,6 +1,6 @@ import { Logger, Scope } from '@nestjs/common'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator'; @@ -16,7 +16,7 @@ export class UpdateSubscriptionJob { protected readonly logger = new Logger(UpdateSubscriptionJob.name); constructor( - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService, private readonly stripeService: StripeService, ) {} @@ -32,7 +32,9 @@ export class UpdateSubscriptionJob { try { const billingSubscriptionItem = - await this.billingService.getBillingSubscriptionItem(data.workspaceId); + await this.billingWorkspaceService.getBillingSubscriptionItem( + data.workspaceId, + ); await this.stripeService.updateSubscriptionItem( billingSubscriptionItem.stripeSubscriptionItemId, diff --git a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts index 32f70758f4ad..4fadaaf5ed7c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts @@ -10,14 +10,14 @@ import { UpdateSubscriptionJobData, } from 'src/engine/core-modules/billing/jobs/update-subscription.job'; import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; @Injectable() export class BillingWorkspaceMemberListener { constructor( @InjectMessageQueue(MessageQueue.billingQueue) private readonly messageQueueService: MessageQueueService, - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, ) {} @OnEvent('workspaceMember.created') @@ -26,7 +26,7 @@ export class BillingWorkspaceMemberListener { payload: ObjectRecordCreateEvent, ) { const isBillingEnabledForWorkspace = - await this.billingService.isBillingEnabledForWorkspace( + await this.billingWorkspaceService.isBillingEnabledForWorkspace( payload.workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts index 4f3264baa090..1bf944a66d7c 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts @@ -22,6 +22,7 @@ export enum FeatureFlagKeys { IsPostgreSQLIntegrationEnabled = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IsStripeIntegrationEnabled = 'IS_STRIPE_INTEGRATION_ENABLED', IsContactCreationForSentAndReceivedEmailsEnabled = 'IS_CONTACT_CREATION_FOR_SENT_AND_RECEIVED_EMAILS_ENABLED', + IsMessagingAliasFetchingEnabled = 'IS_MESSAGING_ALIAS_FETCHING_ENABLED', IsGoogleCalendarSyncV2Enabled = 'IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED', IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts index 0b0f9bbd25ad..a79d3a72901e 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts @@ -5,6 +5,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { IsFeatureEnabledService } from 'src/engine/core-modules/feature-flag/services/is-feature-enabled.service'; @Module({ imports: [ @@ -17,7 +18,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- resolvers: [], }), ], - exports: [], - providers: [], + exports: [IsFeatureEnabledService], + providers: [IsFeatureEnabledService], }) export class FeatureFlagModule {} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/services/is-feature-enabled.service.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/services/is-feature-enabled.service.ts new file mode 100644 index 000000000000..33e3bcc8655c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/services/is-feature-enabled.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/engine/core-modules/feature-flag/feature-flag.entity'; + +@Injectable() +export class IsFeatureEnabledService { + constructor( + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) {} + + public async isFeatureEnabled( + key: FeatureFlagKeys, + workspaceId: string, + ): Promise { + const featureFlag = await this.featureFlagRepository.findOneBy({ + workspaceId, + key, + value: true, + }); + + return !!featureFlag?.value; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts index 9832b41c6d63..f131e5bca20c 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -14,7 +14,7 @@ import { InjectWorkspaceRepository } from 'src/engine/twenty-orm/decorators/inje import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { isDefined } from 'src/utils/is-defined'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; enum OnboardingStepValues { SKIPPED = 'SKIPPED', @@ -33,7 +33,7 @@ type OnboardingKeyValueType = { @Injectable() export class OnboardingService { constructor( - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, private readonly workspaceManagerService: WorkspaceManagerService, private readonly userWorkspaceService: UserWorkspaceService, private readonly keyValuePairService: KeyValuePairService, @@ -45,7 +45,7 @@ export class OnboardingService { private async isSubscriptionIncompleteOnboardingStatus(user: User) { const isBillingEnabledForWorkspace = - await this.billingService.isBillingEnabledForWorkspace( + await this.billingWorkspaceService.isBillingEnabledForWorkspace( user.defaultWorkspaceId, ); @@ -54,7 +54,7 @@ export class OnboardingService { } const currentBillingSubscription = - await this.billingService.getCurrentBillingSubscription({ + await this.billingWorkspaceService.getCurrentBillingSubscription({ workspaceId: user.defaultWorkspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index 87a10f16c6a2..696287311451 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -5,7 +5,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { EmailService } from 'src/engine/integrations/email/email.service'; @@ -46,7 +46,7 @@ describe('WorkspaceService', () => { useValue: {}, }, { - provide: BillingService, + provide: BillingWorkspaceService, useValue: {}, }, { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index ebee588747ad..38a259cb2c71 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -14,7 +14,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity'; import { EmailService } from 'src/engine/integrations/email/email.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; @@ -30,7 +30,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly userWorkspaceRepository: Repository, private readonly workspaceManagerService: WorkspaceManagerService, private readonly userWorkspaceService: UserWorkspaceService, - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, private readonly onboardingService: OnboardingService, @@ -64,7 +64,7 @@ export class WorkspaceService extends TypeOrmQueryService { assert(workspace, 'Workspace not found'); await this.userWorkspaceRepository.delete({ workspaceId: id }); - await this.billingService.deleteSubscription(workspace.id); + await this.billingWorkspaceService.deleteSubscription(workspace.id); await this.workspaceManagerService.delete(id); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index e8e8a78ee605..9840cf1109c0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -22,7 +22,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingService } from 'src/engine/core-modules/billing/billing.service'; +import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service'; import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service'; import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity'; @@ -41,7 +41,7 @@ export class WorkspaceResolver { private readonly workspaceCacheVersionService: WorkspaceCacheVersionService, private readonly userWorkspaceService: UserWorkspaceService, private readonly fileUploadService: FileUploadService, - private readonly billingService: BillingService, + private readonly billingWorkspaceService: BillingWorkspaceService, ) {} @Query(() => Workspace) @@ -122,7 +122,7 @@ export class WorkspaceResolver { async currentBillingSubscription( @Parent() workspace: Workspace, ): Promise { - return this.billingService.getCurrentBillingSubscription({ + return this.billingWorkspaceService.getCurrentBillingSubscription({ workspaceId: workspace.id, }); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts index 804759f55ebb..4908a028140c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts @@ -59,6 +59,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_POSTGRESQL_INTEGRATION_ENABLED: true, IS_STRIPE_INTEGRATION_ENABLED: false, IS_CONTACT_CREATION_FOR_SENT_AND_RECEIVED_EMAILS_ENABLED: true, + IS_MESSAGING_ALIAS_FETCHING_ENABLED: true, IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true, IS_FREE_ACCESS_ENABLED: false, }, @@ -76,6 +77,7 @@ export class AddStandardIdCommand extends CommandRunner { IS_POSTGRESQL_INTEGRATION_ENABLED: true, IS_STRIPE_INTEGRATION_ENABLED: false, IS_CONTACT_CREATION_FOR_SENT_AND_RECEIVED_EMAILS_ENABLED: true, + IS_MESSAGING_ALIAS_FETCHING_ENABLED: true, IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true, IS_FREE_ACCESS_ENABLED: false, }, 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 30c07bac18a2..1d8a89e660e5 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 @@ -140,6 +140,7 @@ export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = { authFailedAt: '20202020-d268-4c6b-baff-400d402b430a', messageChannels: '20202020-24f7-4362-8468-042204d1e445', calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977', + emailAliases: '20202020-8a3d-46be-814f-6228af16c47b', }; export const EVENT_STANDARD_FIELD_IDS = { diff --git a/packages/twenty-server/src/modules/calendar/jobs/google-calendar-sync.job.ts b/packages/twenty-server/src/modules/calendar/jobs/google-calendar-sync.job.ts index 38f90f8b9868..b14dfd99a776 100644 --- a/packages/twenty-server/src/modules/calendar/jobs/google-calendar-sync.job.ts +++ b/packages/twenty-server/src/modules/calendar/jobs/google-calendar-sync.job.ts @@ -2,6 +2,9 @@ import { Logger, Scope } from '@nestjs/common'; import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.service'; import { GoogleCalendarSyncService } from 'src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator'; @@ -21,6 +24,8 @@ export class GoogleCalendarSyncJob { constructor( private readonly googleAPIsRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService, private readonly googleCalendarSyncService: GoogleCalendarSyncService, + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, ) {} @Process(GoogleCalendarSyncJob.name) @@ -29,9 +34,22 @@ export class GoogleCalendarSyncJob { `google calendar sync for workspace ${data.workspaceId} and account ${data.connectedAccountId}`, ); try { + const { connectedAccountId, workspaceId } = data; + + const connectedAccount = await this.connectedAccountRepository.getById( + connectedAccountId, + workspaceId, + ); + + if (!connectedAccount) { + throw new Error( + `No connected account found for ${connectedAccountId} in workspace ${workspaceId}`, + ); + } + await this.googleAPIsRefreshAccessTokenService.refreshAndSaveAccessToken( - data.workspaceId, - data.connectedAccountId, + connectedAccount, + workspaceId, ); } catch (e) { this.logger.error( diff --git a/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts b/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts index db6bfccb5175..da819a40c15b 100644 --- a/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts +++ b/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts @@ -108,9 +108,8 @@ export class GoogleCalendarSyncService { const calendarChannelId = calendarChannel.id; const { events, nextSyncToken } = await this.getEventsFromGoogleCalendar( - refreshToken, + connectedAccount, workspaceId, - connectedAccountId, emailOrDomainToReimport, syncToken, ); @@ -321,9 +320,8 @@ export class GoogleCalendarSyncService { } public async getEventsFromGoogleCalendar( - refreshToken: string, + connectedAccount: ConnectedAccountWorkspaceEntity, workspaceId: string, - connectedAccountId: string, emailOrDomainToReimport?: string, syncToken?: string, ): Promise<{ @@ -332,7 +330,7 @@ export class GoogleCalendarSyncService { }> { const googleCalendarClient = await this.googleCalendarClientProvider.getGoogleCalendarClient( - refreshToken, + connectedAccount, ); const startTime = Date.now(); @@ -360,7 +358,7 @@ export class GoogleCalendarSyncService { await this.calendarChannelRepository.update( { - id: connectedAccountId, + id: connectedAccount.id, }, { syncCursor: '', @@ -368,7 +366,7 @@ export class GoogleCalendarSyncService { ); this.logger.log( - `Sync token is no longer valid for connected account ${connectedAccountId} in workspace ${workspaceId}, resetting sync cursor.`, + `Sync token is no longer valid for connected account ${connectedAccount.id} in workspace ${workspaceId}, resetting sync cursor.`, ); return { @@ -399,9 +397,9 @@ export class GoogleCalendarSyncService { const endTime = Date.now(); this.logger.log( - `google calendar sync for workspace ${workspaceId} and account ${connectedAccountId} getting events list in ${ - endTime - startTime - }ms.`, + `google calendar sync for workspace ${workspaceId} and account ${ + connectedAccount.id + } getting events list in ${endTime - startTime}ms.`, ); return { events, nextSyncToken }; diff --git a/packages/twenty-server/src/modules/calendar/services/providers/calendar-providers.module.ts b/packages/twenty-server/src/modules/calendar/services/providers/calendar-providers.module.ts index 67f8064f7adf..c1309c85f361 100644 --- a/packages/twenty-server/src/modules/calendar/services/providers/calendar-providers.module.ts +++ b/packages/twenty-server/src/modules/calendar/services/providers/calendar-providers.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module'; import { GoogleCalendarClientProvider } from 'src/modules/calendar/services/providers/google-calendar/google-calendar.provider'; +import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module'; @Module({ - imports: [EnvironmentModule], + imports: [EnvironmentModule, OAuth2ClientManagerModule], providers: [GoogleCalendarClientProvider], exports: [GoogleCalendarClientProvider], }) diff --git a/packages/twenty-server/src/modules/calendar/services/providers/google-calendar/google-calendar.provider.ts b/packages/twenty-server/src/modules/calendar/services/providers/google-calendar/google-calendar.provider.ts index 6e2faa04e613..a211ef6ca004 100644 --- a/packages/twenty-server/src/modules/calendar/services/providers/google-calendar/google-calendar.provider.ts +++ b/packages/twenty-server/src/modules/calendar/services/providers/google-calendar/google-calendar.provider.ts @@ -1,18 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { OAuth2Client } from 'google-auth-library'; import { calendar_v3 as calendarV3, google } from 'googleapis'; -import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; @Injectable() export class GoogleCalendarClientProvider { - constructor(private readonly environmentService: EnvironmentService) {} + constructor( + private readonly oAuth2ClientManagerService: OAuth2ClientManagerService, + ) {} public async getGoogleCalendarClient( - refreshToken: string, + connectedAccount: ConnectedAccountWorkspaceEntity, ): Promise { - const oAuth2Client = await this.getOAuth2Client(refreshToken); + const oAuth2Client = + await this.oAuth2ClientManagerService.getOAuth2Client(connectedAccount); const googleCalendarClient = google.calendar({ version: 'v3', @@ -21,24 +24,4 @@ export class GoogleCalendarClientProvider { return googleCalendarClient; } - - private async getOAuth2Client(refreshToken: string): Promise { - const googleCalendarClientId = this.environmentService.get( - 'AUTH_GOOGLE_CLIENT_ID', - ); - const googleCalendarClientSecret = this.environmentService.get( - 'AUTH_GOOGLE_CLIENT_SECRET', - ); - - const oAuth2Client = new google.auth.OAuth2( - googleCalendarClientId, - googleCalendarClientSecret, - ); - - oAuth2Client.setCredentials({ - refresh_token: refreshToken, - }); - - return oAuth2Client; - } } diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts index 1539045266c8..06f690a7e603 100644 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts @@ -16,7 +16,7 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { getUniqueContactsAndHandles } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util'; import { CalendarEventParticipantService } from 'src/modules/calendar/services/calendar-event-participant/calendar-event-participant.service'; -import { filterOutContactsFromCompanyOrWorkspace } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util'; +import { filterOutSelfAndContactsFromCompanyOrWorkspace } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util'; import { MessagingMessageParticipantService } from 'src/modules/messaging/common/services/messaging-message-participant.service'; import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-event-participant.workspace-entity'; @@ -43,7 +43,7 @@ export class CreateCompanyAndContactService { ) {} async createCompaniesAndPeople( - connectedAccountHandle: string, + connectedAccount: ConnectedAccountWorkspaceEntity, contactsToCreate: Contact[], workspaceId: string, transactionManager?: EntityManager, @@ -62,9 +62,9 @@ export class CreateCompanyAndContactService { ); const contactsToCreateFromOtherCompanies = - filterOutContactsFromCompanyOrWorkspace( + filterOutSelfAndContactsFromCompanyOrWorkspace( contactsToCreate, - connectedAccountHandle, + connectedAccount, workspaceMembers, ); @@ -150,7 +150,7 @@ export class CreateCompanyAndContactService { await this.workspaceDataSource?.transaction( async (transactionManager: EntityManager) => { const createdPeople = await this.createCompaniesAndPeople( - connectedAccount.handle, + connectedAccount, contactsBatch, workspaceId, transactionManager, diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts index 23830c6cf3d9..765cd6f7dcc7 100644 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts @@ -1,13 +1,16 @@ import { getDomainNameFromHandle } from 'src/modules/calendar-messaging-participant/utils/get-domain-name-from-handle.util'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { Contact } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type'; -export function filterOutContactsFromCompanyOrWorkspace( +export function filterOutSelfAndContactsFromCompanyOrWorkspace( contacts: Contact[], - selfHandle: string, + connectedAccount: ConnectedAccountWorkspaceEntity, workspaceMembers: WorkspaceMemberWorkspaceEntity[], ): Contact[] { - const selfDomainName = getDomainNameFromHandle(selfHandle); + const selfDomainName = getDomainNameFromHandle(connectedAccount.handle); + + const emailAliases = connectedAccount.emailAliases?.split(',') || []; const workspaceMembersMap = workspaceMembers.reduce( (map, workspaceMember) => { @@ -21,6 +24,7 @@ export function filterOutContactsFromCompanyOrWorkspace( return contacts.filter( (contact) => getDomainNameFromHandle(contact.handle) !== selfDomainName && - !workspaceMembersMap[contact.handle], + !workspaceMembersMap[contact.handle] && + !emailAliases.includes(contact.handle), ); } diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service.ts new file mode 100644 index 000000000000..a8201bc19d20 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { google } from 'googleapis'; + +import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; + +@Injectable() +export class GoogleEmailAliasManagerService { + constructor( + private readonly oAuth2ClientManagerService: OAuth2ClientManagerService, + ) {} + + public async getEmailAliases( + connectedAccount: ConnectedAccountWorkspaceEntity, + ) { + const oAuth2Client = + await this.oAuth2ClientManagerService.getOAuth2Client(connectedAccount); + + const people = google.people({ + version: 'v1', + auth: oAuth2Client, + }); + + const emailsResponse = await people.people.get({ + resourceName: 'people/me', + personFields: 'emailAddresses', + }); + + const emailAddresses = emailsResponse.data.emailAddresses; + + const emailAliases = + emailAddresses + ?.filter((emailAddress) => { + return emailAddress.metadata?.primary !== true; + }) + .map((emailAddress) => { + return emailAddress.value || ''; + }) || []; + + return emailAliases; + } +} diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/email-alias-manager.module.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/email-alias-manager.module.ts new file mode 100644 index 000000000000..e1678e3d7ab6 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/email-alias-manager.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service'; +import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service'; +import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; + +@Module({ + imports: [ + ObjectMetadataRepositoryModule.forFeature([ + ConnectedAccountWorkspaceEntity, + ]), + OAuth2ClientManagerModule, + ], + providers: [EmailAliasManagerService, GoogleEmailAliasManagerService], + exports: [EmailAliasManagerService], +}) +export class EmailAliasManagerModule {} diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts new file mode 100644 index 000000000000..e535bc87d61c --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; + +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; + +@Injectable() +export class EmailAliasManagerService { + constructor( + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, + private readonly googleEmailAliasManagerService: GoogleEmailAliasManagerService, + ) {} + + public async refreshEmailAliases( + connectedAccount: ConnectedAccountWorkspaceEntity, + workspaceId: string, + ) { + let emailAliases: string[]; + + switch (connectedAccount.provider) { + case 'google': + emailAliases = + await this.googleEmailAliasManagerService.getEmailAliases( + connectedAccount, + ); + break; + default: + throw new Error( + `Email alias manager for provider ${connectedAccount.provider} is not implemented`, + ); + } + + await this.connectedAccountRepository.updateEmailAliases( + emailAliases, + connectedAccount.id, + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service.ts new file mode 100644 index 000000000000..ef577eafc702 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +import { OAuth2Client } from 'google-auth-library'; +import { google } from 'googleapis'; + +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; + +@Injectable() +export class GoogleOAuth2ClientManagerService { + constructor(private readonly environmentService: EnvironmentService) {} + + public async getOAuth2Client(refreshToken: string): Promise { + const gmailClientId = this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'); + const gmailClientSecret = this.environmentService.get( + 'AUTH_GOOGLE_CLIENT_SECRET', + ); + + const oAuth2Client = new google.auth.OAuth2( + gmailClientId, + gmailClientSecret, + ); + + oAuth2Client.setCredentials({ + refresh_token: refreshToken, + }); + + return oAuth2Client; + } +} diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts new file mode 100644 index 000000000000..23c65eba1157 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service'; +import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; + +@Module({ + imports: [], + providers: [OAuth2ClientManagerService, GoogleOAuth2ClientManagerService], + exports: [OAuth2ClientManagerService], +}) +export class OAuth2ClientManagerModule {} diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts new file mode 100644 index 000000000000..4f9198ba59bf --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { OAuth2Client } from 'google-auth-library'; + +import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; + +@Injectable() +export class OAuth2ClientManagerService { + constructor( + private readonly googleOAuth2ClientManagerService: GoogleOAuth2ClientManagerService, + ) {} + + public async getOAuth2Client( + connectedAccount: ConnectedAccountWorkspaceEntity, + ): Promise { + const { refreshToken } = connectedAccount; + + switch (connectedAccount.provider) { + case 'google': + return this.googleOAuth2ClientManagerService.getOAuth2Client( + refreshToken, + ); + default: + throw new Error( + `OAuth2 client manager for provider ${connectedAccount.provider} is not implemented`, + ); + } + } +} 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 ce6fd5a16c6b..52a3ff68c1f2 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 @@ -306,4 +306,22 @@ export class ConnectedAccountRepository { return connectedAccount; } + + public async updateEmailAliases( + emailAliases: string[], + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."connectedAccount" SET "emailAliases" = $1 WHERE "id" = $2`, + // TODO: modify emailAliases to be of fieldmetadatatype array + [emailAliases.join(','), 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 f564cd11c30e..1caa85cb2106 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 @@ -16,33 +16,27 @@ export class GoogleAPIRefreshAccessTokenService { ) {} async refreshAndSaveAccessToken( + connectedAccount: ConnectedAccountWorkspaceEntity, workspaceId: string, - connectedAccountId: string, ): Promise { - const connectedAccount = await this.connectedAccountRepository.getById( - connectedAccountId, - workspaceId, - ); - - if (!connectedAccount) { - throw new Error( - `No connected account found for ${connectedAccountId} in workspace ${workspaceId}`, - ); - } - const refreshToken = connectedAccount.refreshToken; if (!refreshToken) { throw new Error( - `No refresh token found for connected account ${connectedAccountId} in workspace ${workspaceId}`, + `No refresh token found for connected account ${connectedAccount.id} in workspace ${workspaceId}`, ); } - const accessToken = await this.refreshAccessToken(refreshToken); await this.connectedAccountRepository.updateAccessToken( accessToken, - connectedAccountId, + connectedAccount.id, + workspaceId, + ); + + await this.connectedAccountRepository.updateAccessToken( + accessToken, + connectedAccount.id, workspaceId, ); diff --git a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts index 3bcae0231b83..a13c7fb2dd09 100644 --- a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts +++ b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts @@ -17,6 +17,8 @@ import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +import { FeatureFlagKeys } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; export enum ConnectedAccountProvider { @@ -89,6 +91,18 @@ export class ConnectedAccountWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsNullable() authFailedAt: Date | null; + @WorkspaceField({ + standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.emailAliases, + type: FieldMetadataType.TEXT, + label: 'Email Aliases', + description: 'Email Aliases', + icon: 'IconMail', + }) + @WorkspaceGate({ + featureFlag: FeatureFlagKeys.IsMessagingAliasFetchingEnabled, + }) + emailAliases: string; + @WorkspaceRelation({ standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner, type: RelationMetadataType.MANY_TO_ONE, diff --git a/packages/twenty-server/src/modules/messaging/common/services/messaging-message.service.ts b/packages/twenty-server/src/modules/messaging/common/services/messaging-message.service.ts index e27c5b3671ee..cb4576b19ca0 100644 --- a/packages/twenty-server/src/modules/messaging/common/services/messaging-message.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/services/messaging-message.service.ts @@ -1,17 +1,15 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; +import { EntityManager } from 'typeorm'; import { v4 } from 'uuid'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository'; -import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository'; import { MessageThreadRepository } from 'src/modules/messaging/common/repositories/message-thread.repository'; import { MessageRepository } from 'src/modules/messaging/common/repositories/message.repository'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; -import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity'; import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity'; import { GmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message'; @@ -19,8 +17,6 @@ import { MessagingMessageThreadService } from 'src/modules/messaging/common/serv @Injectable() export class MessagingMessageService { - private readonly logger = new Logger(MessagingMessageService.name); - constructor( private readonly workspaceDataSourceService: WorkspaceDataSourceService, @InjectObjectMetadataRepository( @@ -29,8 +25,6 @@ export class MessagingMessageService { private readonly messageChannelMessageAssociationRepository: MessageChannelMessageAssociationRepository, @InjectObjectMetadataRepository(MessageWorkspaceEntity) private readonly messageRepository: MessageRepository, - @InjectObjectMetadataRepository(MessageChannelWorkspaceEntity) - private readonly messageChannelRepository: MessageChannelRepository, @InjectObjectMetadataRepository(MessageThreadWorkspaceEntity) private readonly messageThreadRepository: MessageThreadRepository, private readonly messageThreadService: MessagingMessageThreadService, @@ -101,104 +95,6 @@ export class MessagingMessageService { return messageExternalIdsAndIdsMap; } - public async saveMessages( - messages: GmailMessage[], - workspaceDataSource: DataSource, - connectedAccount: ConnectedAccountWorkspaceEntity, - gmailMessageChannelId: string, - workspaceId: string, - ): Promise> { - const messageExternalIdsAndIdsMap = new Map(); - - try { - let keepImporting = true; - - for (const message of messages) { - if (!keepImporting) { - break; - } - - await workspaceDataSource?.transaction( - async (manager: EntityManager) => { - const gmailMessageChannel = - await this.messageChannelRepository.getByIds( - [gmailMessageChannelId], - workspaceId, - manager, - ); - - if (gmailMessageChannel.length === 0) { - this.logger.error( - `No message channel found for connected account ${connectedAccount.id} in workspace ${workspaceId} in saveMessages`, - ); - - keepImporting = false; - - return; - } - - const existingMessageChannelMessageAssociationsCount = - await this.messageChannelMessageAssociationRepository.countByMessageExternalIdsAndMessageChannelId( - [message.externalId], - gmailMessageChannelId, - workspaceId, - manager, - ); - - if (existingMessageChannelMessageAssociationsCount > 0) { - return; - } - - // TODO: This does not handle all thread merging use cases and might create orphan threads. - const savedOrExistingMessageThreadId = - await this.messageThreadService.saveMessageThreadOrReturnExistingMessageThread( - message.headerMessageId, - message.messageThreadExternalId, - workspaceId, - manager, - ); - - if (!savedOrExistingMessageThreadId) { - throw new Error( - `No message thread found for message ${message.headerMessageId} in workspace ${workspaceId} in saveMessages`, - ); - } - - const savedOrExistingMessageId = - await this.saveMessageOrReturnExistingMessage( - message, - savedOrExistingMessageThreadId, - connectedAccount, - workspaceId, - manager, - ); - - messageExternalIdsAndIdsMap.set( - message.externalId, - savedOrExistingMessageId, - ); - - await this.messageChannelMessageAssociationRepository.insert( - gmailMessageChannelId, - savedOrExistingMessageId, - message.externalId, - savedOrExistingMessageThreadId, - message.messageThreadExternalId, - workspaceId, - manager, - ); - }, - ); - } - } catch (error) { - throw new Error( - `Error saving connected account ${connectedAccount.id} messages to workspace ${workspaceId}: ${error.message}`, - ); - } - - return messageExternalIdsAndIdsMap; - } - private async saveMessageOrReturnExistingMessage( message: GmailMessage, messageThreadId: string, @@ -219,8 +115,11 @@ export class MessagingMessageService { const newMessageId = v4(); - const messageDirection = - connectedAccount.handle === message.fromHandle ? 'outgoing' : 'incoming'; + const messageDirection = connectedAccount.emailAliases?.includes( + message.fromHandle, + ) + ? 'outgoing' + : 'incoming'; const receivedAt = new Date(parseInt(message.internalDate)); diff --git a/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts b/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts index 16f9a32bb25c..f3200514b022 100644 --- a/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts @@ -58,6 +58,8 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { value: true, }); + const emailAliases = connectedAccount.emailAliases?.split(',') || []; + const isContactCreationForSentAndReceivedEmailsEnabled = isContactCreationForSentAndReceivedEmailsEnabledFeatureFlag?.value; @@ -80,15 +82,21 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { const messageId = messageExternalIdsAndIdsMap.get(message.externalId); return messageId - ? message.participants.map((participant: Participant) => ({ - ...participant, - messageId, - shouldCreateContact: - messageChannel.isContactAutoCreationEnabled && - (isContactCreationForSentAndReceivedEmailsEnabled || - message.participants.find((p) => p.role === 'from') - ?.handle === connectedAccount.handle), - })) + ? message.participants.map((participant: Participant) => { + const fromHandle = + message.participants.find((p) => p.role === 'from')?.handle || + ''; + + return { + ...participant, + messageId, + shouldCreateContact: + messageChannel.isContactAutoCreationEnabled && + (isContactCreationForSentAndReceivedEmailsEnabled || + emailAliases.includes(fromHandle)) && + !emailAliases.includes(participant.handle), + }; + }) : []; }); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts index 380e4534efb1..0554aad1602b 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts @@ -2,8 +2,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module'; +import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module'; import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.module'; import { BlocklistWorkspaceEntity } from 'src/modules/connected-account/standard-objects/blocklist.workspace-entity'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; @@ -30,6 +33,9 @@ import { MessagingGmailPartialMessageListFetchService } from 'src/modules/messag ]), MessagingCommonModule, TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), + OAuth2ClientManagerModule, + EmailAliasManagerModule, + FeatureFlagModule, ], providers: [ MessagingGmailClientProvider, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/providers/messaging-gmail-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/providers/messaging-gmail-client.provider.ts index 130e335621a1..6ef460f879cd 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/providers/messaging-gmail-client.provider.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/providers/messaging-gmail-client.provider.ts @@ -1,16 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { OAuth2Client } from 'google-auth-library'; import { gmail_v1, google } from 'googleapis'; -import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; @Injectable() export class MessagingGmailClientProvider { - constructor(private readonly environmentService: EnvironmentService) {} + constructor( + private readonly oAuth2ClientManagerService: OAuth2ClientManagerService, + ) {} - public async getGmailClient(refreshToken: string): Promise { - const oAuth2Client = await this.getOAuth2Client(refreshToken); + public async getGmailClient( + connectedAccount: ConnectedAccountWorkspaceEntity, + ): Promise { + const oAuth2Client = + await this.oAuth2ClientManagerService.getOAuth2Client(connectedAccount); const gmailClient = google.gmail({ version: 'v1', @@ -19,22 +24,4 @@ export class MessagingGmailClientProvider { return gmailClient; } - - private async getOAuth2Client(refreshToken: string): Promise { - const gmailClientId = this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'); - const gmailClientSecret = this.environmentService.get( - 'AUTH_GOOGLE_CLIENT_SECRET', - ); - - const oAuth2Client = new google.auth.OAuth2( - gmailClientId, - gmailClientSecret, - ); - - oAuth2Client.setCredentials({ - refresh_token: refreshToken, - }); - - return oAuth2Client; - } } diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-full-message-list-fetch.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-full-message-list-fetch.service.ts index 916f055b018c..12eeda48a37f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-full-message-list-fetch.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-full-message-list-fetch.service.ts @@ -54,9 +54,7 @@ export class MessagingGmailFullMessageListFetchService { ); const gmailClient: gmail_v1.Gmail = - await this.gmailClientProvider.getGmailClient( - connectedAccount.refreshToken, - ); + await this.gmailClientProvider.getGmailClient(connectedAccount); const { error: gmailError } = await this.fetchAllMessageIdsFromGmailAndStoreInCache( diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-messages-import.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-messages-import.service.ts index 4f26d4d8e3a5..74236f4e917f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-messages-import.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-messages-import.service.ts @@ -20,6 +20,9 @@ import { MessagingGmailFetchMessagesByBatchesService } from 'src/modules/messagi import { MessagingErrorHandlingService } from 'src/modules/messaging/common/services/messaging-error-handling.service'; import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service'; import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository'; +import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service'; +import { IsFeatureEnabledService } from 'src/engine/core-modules/feature-flag/services/is-feature-enabled.service'; +import { FeatureFlagKeys } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; @Injectable() @@ -41,6 +44,8 @@ export class MessagingGmailMessagesImportService { private readonly blocklistRepository: BlocklistRepository, @InjectObjectMetadataRepository(MessageChannelWorkspaceEntity) private readonly messageChannelRepository: MessageChannelRepository, + private readonly emailAliasManagerService: EmailAliasManagerService, + private readonly isFeatureEnabledService: IsFeatureEnabledService, @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) private readonly connectedAccountRepository: ConnectedAccountRepository, ) {} @@ -78,8 +83,8 @@ export class MessagingGmailMessagesImportService { try { accessToken = await this.googleAPIsRefreshAccessTokenService.refreshAndSaveAccessToken( + connectedAccount, workspaceId, - connectedAccount.id, ); } catch (error) { await this.messagingTelemetryService.track({ @@ -103,6 +108,30 @@ export class MessagingGmailMessagesImportService { return; } + if ( + await this.isFeatureEnabledService.isFeatureEnabled( + FeatureFlagKeys.IsMessagingAliasFetchingEnabled, + workspaceId, + ) + ) { + try { + await this.emailAliasManagerService.refreshEmailAliases( + connectedAccount, + workspaceId, + ); + } catch (error) { + await this.gmailErrorHandlingService.handleGmailError( + { + code: error.code, + reason: error.message, + }, + 'messages-import', + messageChannel, + workspaceId, + ); + } + } + const messageIdsToFetch = (await this.cacheStorage.setPop( `messages-to-import:${workspaceId}:gmail:${messageChannel.id}`, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-partial-message-list-fetch.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-partial-message-list-fetch.service.ts index 63c2fa84b6df..069180e5d7e1 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-partial-message-list-fetch.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/messaging-gmail-partial-message-list-fetch.service.ts @@ -52,9 +52,7 @@ export class MessagingGmailPartialMessageListFetchService { const lastSyncHistoryId = messageChannel.syncCursor; const gmailClient: gmail_v1.Gmail = - await this.gmailClientProvider.getGmailClient( - connectedAccount.refreshToken, - ); + await this.gmailClientProvider.getGmailClient(connectedAccount); const { history, historyId, error } = await this.gmailGetHistoryService.getHistory(