diff --git a/package.json b/package.json
index 41e8e4b35..c9c7f6f1e 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@mdx-js/react": "^3.0.0",
+ "@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
"@nestjs/cli": "^9.0.0",
@@ -201,6 +202,7 @@
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
+ "@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
diff --git a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx
index c3f381c1b..67fc042a9 100644
--- a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx
@@ -1,7 +1,7 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { IconRefresh } from 'twenty-ui';
export const InformationBannerReconnectAccountEmailAliases = () => {
@@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
);
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
+ const { triggerApisOAuth } = useTriggerApisOAuth();
if (!accountToReconnect) {
return null;
@@ -20,7 +20,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
message={`Please reconnect your mailbox ${accountToReconnect?.handle} to update your email aliases:`}
buttonTitle="Reconnect"
buttonIcon={IconRefresh}
- buttonOnClick={() => triggerGoogleApisOAuth()}
+ buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
/>
);
};
diff --git a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx
index 7f74a129b..306452b56 100644
--- a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx
@@ -1,7 +1,7 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { IconRefresh } from 'twenty-ui';
export const InformationBannerReconnectAccountInsufficientPermissions = () => {
@@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
);
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
+ const { triggerApisOAuth } = useTriggerApisOAuth();
if (!accountToReconnect) {
return null;
@@ -21,7 +21,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
reconnect for updates:`}
buttonTitle="Reconnect"
buttonIcon={IconRefresh}
- buttonOnClick={() => triggerGoogleApisOAuth()}
+ buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
/>
);
};
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx
index 238371241..6000afa1f 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
-import { IconGoogle } from 'twenty-ui';
+import { IconComponent, IconGoogle, IconMicrosoft } from 'twenty-ui';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard';
@@ -9,6 +9,11 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SettingsAccountsConnectedAccountsRowRightContainer } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer';
import { SettingsListCard } from '../../components/SettingsListCard';
+const ProviderIcons: { [k: string]: IconComponent } = {
+ google: IconGoogle,
+ microsoft: IconMicrosoft,
+};
+
export const SettingsAccountsConnectedAccountsListCard = ({
accounts,
loading,
@@ -27,7 +32,7 @@ export const SettingsAccountsConnectedAccountsListCard = ({
items={accounts}
getItemLabel={(account) => account.handle}
isLoading={loading}
- RowIcon={IconGoogle}
+ RowIconFn={(row) => ProviderIcons[row.provider]}
RowRightComponent={({ item: account }) => (
)}
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx
index 8500264c4..d532691fc 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx
@@ -1,7 +1,14 @@
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
+import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
-import { Button, Card, CardContent, CardHeader, IconGoogle } from 'twenty-ui';
-
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ IconGoogle,
+ IconMicrosoft,
+} from 'twenty-ui';
const StyledHeader = styled(CardHeader)`
align-items: center;
@@ -12,6 +19,7 @@ const StyledHeader = styled(CardHeader)`
const StyledBody = styled(CardContent)`
display: flex;
justify-content: center;
+ gap: ${({ theme }) => theme.spacing(2)};
`;
type SettingsAccountsListEmptyStateCardProps = {
@@ -21,11 +29,10 @@ type SettingsAccountsListEmptyStateCardProps = {
export const SettingsAccountsListEmptyStateCard = ({
label,
}: SettingsAccountsListEmptyStateCardProps) => {
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
-
- const handleOnClick = async () => {
- await triggerGoogleApisOAuth();
- };
+ const { triggerApisOAuth } = useTriggerApisOAuth();
+ const isMicrosoftSyncEnabled = useIsFeatureEnabled(
+ 'IS_MICROSOFT_SYNC_ENABLED',
+ );
return (
@@ -35,8 +42,16 @@ export const SettingsAccountsListEmptyStateCard = ({
Icon={IconGoogle}
title="Connect with Google"
variant="secondary"
- onClick={handleOnClick}
+ onClick={() => triggerApisOAuth('google')}
/>
+ {isMicrosoftSyncEnabled && (
+
);
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx
index de3da4661..d4edf45e6 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsRowDropdownMenu.tsx
@@ -12,7 +12,7 @@ import {
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -35,8 +35,7 @@ export const SettingsAccountsRowDropdownMenu = ({
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
});
-
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
+ const { triggerApisOAuth } = useTriggerApisOAuth();
return (
{
- triggerGoogleApisOAuth();
+ triggerApisOAuth(account.provider);
closeDropdown();
}}
/>
diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerApiOAuth.ts
similarity index 55%
rename from packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts
rename to packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerApiOAuth.ts
index d17e4242a..f66f6ca6d 100644
--- a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts
+++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerApiOAuth.ts
@@ -8,21 +8,35 @@ import {
useGenerateTransientTokenMutation,
} from '~/generated/graphql';
-export const useTriggerGoogleApisOAuth = () => {
+const getProviderUrl = (provider: string) => {
+ switch (provider) {
+ case 'google':
+ return 'google-apis';
+ case 'microsoft':
+ return 'microsoft-apis';
+ default:
+ throw new Error(`Provider ${provider} is not supported`);
+ }
+};
+
+export const useTriggerApisOAuth = () => {
const [generateTransientToken] = useGenerateTransientTokenMutation();
- const triggerGoogleApisOAuth = useCallback(
- async ({
- redirectLocation,
- messageVisibility,
- calendarVisibility,
- loginHint,
- }: {
- redirectLocation?: AppPath | string;
- messageVisibility?: MessageChannelVisibility;
- calendarVisibility?: CalendarChannelVisibility;
- loginHint?: string;
- } = {}) => {
+ const triggerApisOAuth = useCallback(
+ async (
+ provider: string,
+ {
+ redirectLocation,
+ messageVisibility,
+ calendarVisibility,
+ loginHint,
+ }: {
+ redirectLocation?: AppPath | string;
+ messageVisibility?: MessageChannelVisibility;
+ calendarVisibility?: CalendarChannelVisibility;
+ loginHint?: string;
+ } = {},
+ ) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
const transientToken = await generateTransientToken();
@@ -46,10 +60,10 @@ export const useTriggerGoogleApisOAuth = () => {
params += loginHint ? `&loginHint=${loginHint}` : '';
- window.location.href = `${authServerUrl}/auth/google-apis?${params}`;
+ window.location.href = `${authServerUrl}/auth/${getProviderUrl(provider)}?${params}`;
},
[generateTransientToken],
);
- return { triggerGoogleApisOAuth };
+ return { triggerApisOAuth };
};
diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
index d1a18ff2f..4dafbc3b4 100644
--- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
+++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
@@ -2,7 +2,7 @@ import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
@@ -38,7 +38,8 @@ export const WorkflowEditActionFormSendEmail = (
) => {
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
+ const { triggerApisOAuth } = useTriggerApisOAuth();
+
const workflowId = useRecoilValue(workflowIdState);
const redirectUrl = `/object/workflow/${workflowId}`;
@@ -66,7 +67,7 @@ export const WorkflowEditActionFormSendEmail = (
!isDefined(scopes) ||
!isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE))
) {
- await triggerGoogleApisOAuth({
+ await triggerApisOAuth('google', {
redirectLocation: redirectUrl,
loginHint: connectedAccount.handle,
});
@@ -183,7 +184,7 @@ export const WorkflowEditActionFormSendEmail = (
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
- triggerGoogleApisOAuth({ redirectLocation: redirectUrl }),
+ triggerApisOAuth('google', { redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
index 60c7579ac..c8f427d8c 100644
--- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
+++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
@@ -16,4 +16,5 @@ export type FeatureFlagKey =
| 'IS_SSO_ENABLED'
| 'IS_UNIQUE_INDEXES_ENABLED'
| 'IS_ARRAY_AND_JSON_FILTER_ENABLED'
+ | 'IS_MICROSOFT_SYNC_ENABLED'
| 'IS_ADVANCED_FILTERS_ENABLED';
diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
index 4657a2aba..e8945edb7 100644
--- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
+++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
@@ -10,10 +10,10 @@ import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingSyncEmailsSettingsCard } from '@/onboarding/components/OnboardingSyncEmailsSettingsCard';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
-import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
+import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { AppPath } from '@/types/AppPath';
import {
CalendarChannelVisibility,
@@ -38,7 +38,7 @@ const StyledActionLinkContainer = styled.div`
export const SyncEmails = () => {
const theme = useTheme();
- const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
+ const { triggerApisOAuth } = useTriggerApisOAuth();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const currentUser = useRecoilValue(currentUserState);
const [visibility, setVisibility] = useState(
@@ -53,7 +53,7 @@ export const SyncEmails = () => {
? CalendarChannelVisibility.ShareEverything
: CalendarChannelVisibility.Metadata;
- await triggerGoogleApisOAuth({
+ await triggerApisOAuth('google', {
redirectLocation: AppPath.Index,
messageVisibility: visibility,
calendarVisibility: calendarChannelVisibility,
diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example
index 2006ed2ee..6054688dc 100644
--- a/packages/twenty-server/.env.example
+++ b/packages/twenty-server/.env.example
@@ -29,6 +29,7 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
# AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id
# AUTH_MICROSOFT_CLIENT_SECRET=replace_me_with_azure_client_secret
# AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect
+# AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token
# AUTH_GOOGLE_ENABLED=false
# AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id
# AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret
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 0313ed5dc..0c75053e1 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
@@ -75,6 +75,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
+ {
+ key: FeatureFlagKey.IsMicrosoftSyncEnabled,
+ workspaceId: workspaceId,
+ value: true,
+ },
{
key: FeatureFlagKey.IsAdvancedFiltersEnabled,
workspaceId: workspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
index 9386c2669..2d6fc31b6 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
@@ -8,11 +8,13 @@ import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
+import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
+import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
@@ -80,6 +82,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
GoogleAuthController,
MicrosoftAuthController,
GoogleAPIsAuthController,
+ MicrosoftAPIsAuthController,
VerifyAuthController,
SSOAuthController,
],
@@ -90,6 +93,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SamlAuthStrategy,
AuthResolver,
GoogleAPIsService,
+ MicrosoftAPIsService,
AppTokenService,
AccessTokenService,
LoginTokenService,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts
new file mode 100644
index 000000000..d0aec9e12
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts
@@ -0,0 +1,105 @@
+import {
+ Controller,
+ Get,
+ Req,
+ Res,
+ UseFilters,
+ UseGuards,
+} from '@nestjs/common';
+
+import { Response } from 'express';
+
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
+import { MicrosoftAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard';
+import { MicrosoftAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/mircosoft-apis-oauth-request-code.guard';
+import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
+import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
+import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
+
+@Controller('auth/microsoft-apis')
+@UseFilters(AuthRestApiExceptionFilter)
+export class MicrosoftAPIsAuthController {
+ constructor(
+ private readonly microsoftAPIsService: MicrosoftAPIsService,
+ private readonly transientTokenService: TransientTokenService,
+ private readonly environmentService: EnvironmentService,
+ private readonly onboardingService: OnboardingService,
+ ) {}
+
+ @Get()
+ @UseGuards(MicrosoftAPIsOauthRequestCodeGuard)
+ async MicrosoftAuth() {
+ // As this method is protected by Microsoft Auth guard, it will trigger Microsoft SSO flow
+ return;
+ }
+
+ @Get('get-access-token')
+ @UseGuards(MicrosoftAPIsOauthExchangeCodeForTokenGuard)
+ async MicrosoftAuthGetAccessToken(
+ @Req() req: MicrosoftAPIsRequest,
+ @Res() res: Response,
+ ) {
+ const { user } = req;
+
+ const {
+ emails,
+ accessToken,
+ refreshToken,
+ transientToken,
+ redirectLocation,
+ calendarVisibility,
+ messageVisibility,
+ } = user;
+
+ const { workspaceMemberId, userId, workspaceId } =
+ await this.transientTokenService.verifyTransientToken(transientToken);
+
+ const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
+
+ if (demoWorkspaceIds.includes(workspaceId)) {
+ throw new AuthException(
+ 'Cannot connect Microsoft account to demo workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ if (!workspaceId) {
+ throw new AuthException(
+ 'Workspace not found',
+ AuthExceptionCode.WORKSPACE_NOT_FOUND,
+ );
+ }
+
+ const handle = emails[0].value;
+
+ await this.microsoftAPIsService.refreshMicrosoftRefreshToken({
+ handle,
+ workspaceMemberId: workspaceMemberId,
+ workspaceId: workspaceId,
+ accessToken,
+ refreshToken,
+ calendarVisibility,
+ messageVisibility,
+ });
+
+ if (userId) {
+ await this.onboardingService.setOnboardingConnectAccountPending({
+ userId,
+ workspaceId,
+ value: false,
+ });
+ }
+
+ return res.redirect(
+ `${this.environmentService.get('FRONT_BASE_URL')}${
+ redirectLocation || '/settings/accounts'
+ }`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts
new file mode 100644
index 000000000..db94e5d1f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts
@@ -0,0 +1,31 @@
+import { ExecutionContext, Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+import { MicrosoftAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy';
+import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+@Injectable()
+export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
+ 'microsoft-apis',
+) {
+ constructor(private readonly environmentService: EnvironmentService) {
+ super();
+ }
+
+ async canActivate(context: ExecutionContext) {
+ const request = context.switchToHttp().getRequest();
+ const state = JSON.parse(request.query.state);
+
+ new MicrosoftAPIsOauthExchangeCodeForTokenStrategy(this.environmentService);
+
+ 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/mircosoft-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/mircosoft-apis-oauth-request-code.guard.ts
new file mode 100644
index 000000000..427c8fde6
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/guards/mircosoft-apis-oauth-request-code.guard.ts
@@ -0,0 +1,62 @@
+import { ExecutionContext, Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { MicrosoftAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-request-code.auth.strategy';
+import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
+import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
+
+@Injectable()
+export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
+ 'microsoft-apis',
+) {
+ constructor(
+ private readonly environmentService: EnvironmentService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly transientTokenService: TransientTokenService,
+ ) {
+ super({
+ prompt: 'select_account',
+ });
+ }
+
+ async canActivate(context: ExecutionContext) {
+ const request = context.switchToHttp().getRequest();
+
+ const { workspaceId } =
+ await this.transientTokenService.verifyTransientToken(
+ request.query.transientToken,
+ );
+ const isMicrosoftSyncEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsMicrosoftSyncEnabled,
+ workspaceId,
+ );
+
+ if (!isMicrosoftSyncEnabled) {
+ throw new AuthException(
+ 'Microsoft sync is not enabled',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ new MicrosoftAPIsOauthRequestCodeStrategy(this.environmentService);
+ setRequestExtraParams(request, {
+ transientToken: request.query.transientToken,
+ redirectLocation: request.query.redirectLocation,
+ calendarVisibility: request.query.calendarVisibility,
+ messageVisibility: request.query.messageVisibility,
+ loginHint: request.query.loginHint,
+ });
+
+ const activate = (await super.canActivate(context)) as boolean;
+
+ return activate;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
index 04c77d2d9..ad60b010d 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
@@ -3,14 +3,17 @@ import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
+import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
CalendarEventListFetchJob,
- CalendarEventsImportJobData,
+ CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import {
CalendarChannelVisibility,
@@ -33,9 +36,6 @@ import {
MessagingMessageListFetchJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
-import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
-import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@Injectable()
export class GoogleAPIsService {
@@ -222,7 +222,7 @@ export class GoogleAPIsService {
});
for (const calendarChannel of calendarChannels) {
- await this.calendarQueueService.add(
+ await this.calendarQueueService.add(
CalendarEventListFetchJob.name,
{
calendarChannelId: calendarChannel.id,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts
new file mode 100644
index 000000000..b8f619722
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts
@@ -0,0 +1,212 @@
+import { Injectable } from '@nestjs/common';
+
+import { EntityManager } from 'typeorm';
+import { v4 } from 'uuid';
+
+import { getMicrosoftApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes';
+import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import {
+ CalendarEventListFetchJob,
+ CalendarEventListFetchJobData,
+} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
+import {
+ CalendarChannelVisibility,
+ CalendarChannelWorkspaceEntity,
+} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
+import {
+ ConnectedAccountProvider,
+ ConnectedAccountWorkspaceEntity,
+} from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
+import {
+ MessageChannelSyncStage,
+ MessageChannelSyncStatus,
+ MessageChannelType,
+ MessageChannelVisibility,
+ MessageChannelWorkspaceEntity,
+} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
+import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
+
+@Injectable()
+export class MicrosoftAPIsService {
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ @InjectMessageQueue(MessageQueue.calendarQueue)
+ private readonly calendarQueueService: MessageQueueService,
+ private readonly accountsToReconnectService: AccountsToReconnectService,
+ ) {}
+
+ async refreshMicrosoftRefreshToken(input: {
+ handle: string;
+ workspaceMemberId: string;
+ workspaceId: string;
+ accessToken: string;
+ refreshToken: string;
+ calendarVisibility: CalendarChannelVisibility | undefined;
+ messageVisibility: MessageChannelVisibility | undefined;
+ }) {
+ const {
+ handle,
+ workspaceId,
+ workspaceMemberId,
+ calendarVisibility,
+ messageVisibility,
+ } = input;
+
+ const connectedAccountRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ workspaceId,
+ 'connectedAccount',
+ );
+
+ const connectedAccount = await connectedAccountRepository.findOne({
+ where: { handle, accountOwnerId: workspaceMemberId },
+ });
+
+ const existingAccountId = connectedAccount?.id;
+ const newOrExistingConnectedAccountId = existingAccountId ?? v4();
+
+ const calendarChannelRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ workspaceId,
+ 'calendarChannel',
+ );
+
+ const messageChannelRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ workspaceId,
+ 'messageChannel',
+ );
+
+ const workspaceDataSource =
+ await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspaceId);
+
+ const scopes = getMicrosoftApisOauthScopes();
+
+ await workspaceDataSource.transaction(async (manager: EntityManager) => {
+ if (!existingAccountId) {
+ await connectedAccountRepository.save(
+ {
+ id: newOrExistingConnectedAccountId,
+ handle,
+ provider: ConnectedAccountProvider.MICROSOFT,
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken,
+ accountOwnerId: workspaceMemberId,
+ scopes,
+ },
+ {},
+ manager,
+ );
+
+ // TODO: Modify this when the email sync is implemented
+ await messageChannelRepository.save(
+ {
+ id: v4(),
+ connectedAccountId: newOrExistingConnectedAccountId,
+ type: MessageChannelType.EMAIL,
+ handle,
+ visibility:
+ messageVisibility || MessageChannelVisibility.SHARE_EVERYTHING,
+ syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
+ syncStage: MessageChannelSyncStage.FAILED,
+ },
+ {},
+ manager,
+ );
+
+ await calendarChannelRepository.save(
+ {
+ id: v4(),
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ visibility:
+ calendarVisibility || CalendarChannelVisibility.SHARE_EVERYTHING,
+ },
+ {},
+ manager,
+ );
+ } else {
+ await connectedAccountRepository.update(
+ {
+ id: newOrExistingConnectedAccountId,
+ },
+ {
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken,
+ scopes,
+ },
+ manager,
+ );
+
+ const workspaceMemberRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ workspaceId,
+ 'workspaceMember',
+ );
+
+ const workspaceMember = await workspaceMemberRepository.findOneOrFail({
+ where: { id: workspaceMemberId },
+ });
+
+ const userId = workspaceMember.userId;
+
+ await this.accountsToReconnectService.removeAccountToReconnect(
+ userId,
+ workspaceId,
+ newOrExistingConnectedAccountId,
+ );
+
+ // TODO: Modify this when the email sync is implemented
+ await messageChannelRepository.update(
+ {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ },
+ {
+ syncStage: MessageChannelSyncStage.FAILED,
+ syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
+ syncCursor: '',
+ syncStageStartedAt: null,
+ },
+ manager,
+ );
+ }
+ });
+
+ // TODO: Uncomment this when the email sync is implemented
+ // const messageChannels = await messageChannelRepository.find({
+ // where: {
+ // connectedAccountId: newOrExistingConnectedAccountId,
+ // },
+ // });
+
+ // for (const messageChannel of messageChannels) {
+ // await this.messageQueueService.add(
+ // MessagingMessageListFetchJob.name,
+ // {
+ // workspaceId,
+ // messageChannelId: messageChannel.id,
+ // },
+ // );
+ // }
+
+ const calendarChannels = await calendarChannelRepository.find({
+ where: {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ },
+ });
+
+ for (const calendarChannel of calendarChannels) {
+ await this.calendarQueueService.add(
+ CalendarEventListFetchJob.name,
+ {
+ calendarChannelId: calendarChannel.id,
+ workspaceId,
+ },
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts
new file mode 100644
index 000000000..8b506a980
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts
@@ -0,0 +1,31 @@
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+
+import { Strategy } from 'passport-microsoft';
+
+import { getMicrosoftApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+export type MicrosoftAPIScopeConfig = {
+ isCalendarEnabled?: boolean;
+ isMessagingAliasFetchingEnabled?: boolean;
+};
+
+@Injectable()
+export class MicrosoftAPIsOauthCommonStrategy extends PassportStrategy(
+ Strategy,
+ 'microsoft-apis',
+) {
+ constructor(environmentService: EnvironmentService) {
+ const scopes = getMicrosoftApisOauthScopes();
+
+ super({
+ clientID: environmentService.get('AUTH_MICROSOFT_CLIENT_ID'),
+ clientSecret: environmentService.get('AUTH_MICROSOFT_CLIENT_SECRET'),
+ tenant: environmentService.get('AUTH_MICROSOFT_TENANT_ID'),
+ callbackURL: environmentService.get('AUTH_MICROSOFT_APIS_CALLBACK_URL'),
+ scope: scopes,
+ passReqToCallback: true,
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts
new file mode 100644
index 000000000..ef40f3311
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@nestjs/common';
+
+import { VerifyCallback } from 'passport-google-oauth20';
+
+import { MicrosoftAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy';
+import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+export type MicrosoftAPIScopeConfig = {
+ isCalendarEnabled?: boolean;
+};
+
+@Injectable()
+export class MicrosoftAPIsOauthExchangeCodeForTokenStrategy extends MicrosoftAPIsOauthCommonStrategy {
+ constructor(environmentService: EnvironmentService) {
+ super(environmentService);
+ }
+
+ async validate(
+ request: MicrosoftAPIsRequest,
+ 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: MicrosoftAPIsRequest['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/microsoft-apis-oauth-request-code.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-request-code.auth.strategy.ts
new file mode 100644
index 000000000..10ba1aeca
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-request-code.auth.strategy.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+
+import { MicrosoftAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy';
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+@Injectable()
+export class MicrosoftAPIsOauthRequestCodeStrategy extends MicrosoftAPIsOauthCommonStrategy {
+ constructor(environmentService: EnvironmentService) {
+ super(environmentService);
+ }
+
+ authenticate(req: any, options: any) {
+ options = {
+ ...options,
+ accessType: 'offline',
+ prompt: 'consent',
+ loginHint: req.params.loginHint,
+ 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/types/microsoft-api-request.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/microsoft-api-request.type.ts
new file mode 100644
index 000000000..6d6da36b2
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/types/microsoft-api-request.type.ts
@@ -0,0 +1,23 @@
+import { Request } from 'express';
+
+import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
+
+export type MicrosoftAPIsRequest = Omit<
+ Request,
+ 'user' | 'workspace' | 'workspaceMetadataVersion'
+> & {
+ 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/get-microsoft-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts
new file mode 100644
index 000000000..e8353e88b
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts
@@ -0,0 +1,12 @@
+export const getMicrosoftApisOauthScopes = () => {
+ const scopes = [
+ 'openid',
+ 'email',
+ 'profile',
+ 'offline_access',
+ 'Mail.Read',
+ 'Calendars.Read',
+ ];
+
+ return scopes;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
index 89a075a03..a50ef4280 100644
--- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
+++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts
@@ -201,6 +201,10 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CALLBACK_URL: string;
+ @IsUrl({ require_tld: false })
+ @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
+ AUTH_MICROSOFT_APIS_CALLBACK_URL: string;
+
@CastToBoolean()
@IsOptional()
@IsBoolean()
diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
index 2b2130548..9bf22bc2a 100644
--- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts
@@ -14,5 +14,6 @@ export enum FeatureFlagKey {
IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED',
IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED',
IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED',
+ IsMicrosoftSyncEnabled = 'IS_MICROSOFT_SYNC_ENABLED',
IsAdvancedFiltersEnabled = 'IS_ADVANCED_FILTERS_ENABLED',
}
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
index b7692671e..348204c6e 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
@@ -35,7 +35,6 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
-import { isDefined } from 'src/utils/is-defined';
const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null;
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module.ts
index 0385ae884..f318c947d 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module.ts
@@ -10,14 +10,19 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarEventCleanerModule } from 'src/modules/calendar/calendar-event-cleaner/calendar-event-cleaner.module';
import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command';
+import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command';
import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command';
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
+import { CalendarEventsImportCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
import { GoogleCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/google-calendar-driver.module';
+import { MicrosoftCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module';
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
+import { CalendarEventsImportJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job';
import { CalendarOngoingStaleJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-ongoing-stale.job';
import { CalendarEventImportErrorHandlerService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service';
import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
+import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
import { CalendarGetCalendarEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module';
@@ -39,6 +44,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
WorkspaceDataSourceModule,
CalendarEventCleanerModule,
GoogleCalendarDriverModule,
+ MicrosoftCalendarDriverModule,
BillingModule,
RefreshAccessTokenManagerModule,
CalendarEventParticipantManagerModule,
@@ -48,16 +54,20 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
providers: [
CalendarChannelSyncStatusService,
CalendarEventsImportService,
+ CalendarFetchEventsService,
CalendarEventImportErrorHandlerService,
CalendarGetCalendarEventsService,
CalendarSaveEventsService,
CalendarEventListFetchCronJob,
CalendarEventListFetchCronCommand,
CalendarEventListFetchJob,
+ CalendarEventsImportCronJob,
+ CalendarEventsImportCronCommand,
+ CalendarEventsImportJob,
CalendarOngoingStaleCronJob,
CalendarOngoingStaleCronCommand,
CalendarOngoingStaleJob,
],
- exports: [CalendarEventsImportService],
+ exports: [CalendarEventsImportService, CalendarFetchEventsService],
})
export class CalendarEventImportManagerModule {}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/constants/calendar-event-import-batch-size.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/constants/calendar-event-import-batch-size.ts
new file mode 100644
index 000000000..a9d6e86b1
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/constants/calendar-event-import-batch-size.ts
@@ -0,0 +1 @@
+export const CALENDAR_EVENT_IMPORT_BATCH_SIZE = 100;
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command.ts
index d49131893..7fce8ebdb 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command.ts
@@ -3,10 +3,8 @@ import { Command, CommandRunner } from 'nest-commander';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
-import {
- CALENDAR_EVENTS_IMPORT_CRON_PATTERN,
- CalendarEventListFetchCronJob,
-} from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
+import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
+import { CALENDAR_EVENTS_IMPORT_CRON_PATTERN } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
@Command({
name: 'cron:calendar:calendar-event-list-fetch',
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command.ts
new file mode 100644
index 000000000..a73bda1e9
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command.ts
@@ -0,0 +1,32 @@
+import { Command, CommandRunner } from 'nest-commander';
+
+import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
+import {
+ CALENDAR_EVENTS_IMPORT_CRON_PATTERN,
+ CalendarEventsImportCronJob,
+} from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
+
+@Command({
+ name: 'cron:calendar:calendar-events-import',
+ description: 'Starts a cron job to import the calendar events',
+})
+export class CalendarEventsImportCronCommand extends CommandRunner {
+ constructor(
+ @InjectMessageQueue(MessageQueue.cronQueue)
+ private readonly messageQueueService: MessageQueueService,
+ ) {
+ super();
+ }
+
+ async run(): Promise {
+ await this.messageQueueService.addCron(
+ CalendarEventsImportCronJob.name,
+ undefined,
+ {
+ repeat: { pattern: CALENDAR_EVENTS_IMPORT_CRON_PATTERN },
+ },
+ );
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job.ts
index d59e9db71..d755b2cb9 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job.ts
@@ -16,11 +16,11 @@ import {
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
CalendarEventListFetchJob,
- CalendarEventsImportJobData,
+ CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
-export const CALENDAR_EVENTS_IMPORT_CRON_PATTERN = '*/5 * * * *';
+export const CALENDAR_EVENT_LIST_FETCH_CRON_PATTERN = '*/5 * * * *';
@Processor({
queueName: MessageQueue.cronQueue,
@@ -38,7 +38,7 @@ export class CalendarEventListFetchCronJob {
@Process(CalendarEventListFetchCronJob.name)
@SentryCronMonitor(
CalendarEventListFetchCronJob.name,
- CALENDAR_EVENTS_IMPORT_CRON_PATTERN,
+ CALENDAR_EVENT_LIST_FETCH_CRON_PATTERN,
)
async handle(): Promise {
console.time('CalendarEventListFetchCronJob time');
@@ -68,7 +68,7 @@ export class CalendarEventListFetchCronJob {
});
for (const calendarChannel of calendarChannels) {
- await this.messageQueueService.add(
+ await this.messageQueueService.add(
CalendarEventListFetchJob.name,
{
calendarChannelId: calendarChannel.id,
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job.ts
new file mode 100644
index 000000000..8e9c02cb3
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job.ts
@@ -0,0 +1,87 @@
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { Equal, Repository } from 'typeorm';
+
+import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
+import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
+import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
+import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
+import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
+import {
+ Workspace,
+ WorkspaceActivationStatus,
+} from 'src/engine/core-modules/workspace/workspace.entity';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { CalendarEventListFetchJobData } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
+import { CalendarEventsImportJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job';
+import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+
+export const CALENDAR_EVENTS_IMPORT_CRON_PATTERN = '*/1 * * * *';
+
+@Processor({
+ queueName: MessageQueue.cronQueue,
+})
+export class CalendarEventsImportCronJob {
+ constructor(
+ @InjectRepository(Workspace, 'core')
+ private readonly workspaceRepository: Repository,
+ @InjectMessageQueue(MessageQueue.calendarQueue)
+ private readonly messageQueueService: MessageQueueService,
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ private readonly exceptionHandlerService: ExceptionHandlerService,
+ ) {}
+
+ @Process(CalendarEventsImportCronJob.name)
+ @SentryCronMonitor(
+ CalendarEventsImportCronJob.name,
+ CALENDAR_EVENTS_IMPORT_CRON_PATTERN,
+ )
+ async handle(): Promise {
+ console.time('CalendarEventsImportCronJob time');
+
+ const activeWorkspaces = await this.workspaceRepository.find({
+ where: {
+ activationStatus: WorkspaceActivationStatus.ACTIVE,
+ },
+ });
+
+ for (const activeWorkspace of activeWorkspaces) {
+ try {
+ const calendarChannelRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ activeWorkspace.id,
+ 'calendarChannel',
+ );
+
+ const calendarChannels = await calendarChannelRepository.find({
+ where: {
+ isSyncEnabled: true,
+ syncStage: Equal(
+ CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
+ ),
+ },
+ });
+
+ for (const calendarChannel of calendarChannels) {
+ await this.messageQueueService.add(
+ CalendarEventsImportJob.name,
+ {
+ calendarChannelId: calendarChannel.id,
+ workspaceId: activeWorkspace.id,
+ },
+ );
+ }
+ } catch (error) {
+ this.exceptionHandlerService.captureExceptions([error], {
+ user: {
+ workspaceId: activeWorkspace.id,
+ },
+ });
+ }
+ }
+
+ console.timeEnd('CalendarEventsImportCronJob time');
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts
index a140b8bfb..c540a8b55 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts
@@ -72,6 +72,7 @@ export class GoogleCalendarGetEventsService {
}
return {
+ fullEvents: true,
calendarEvents: formatGoogleCalendarEvents(events),
nextSyncCursor: nextSyncToken || '',
};
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module.ts
new file mode 100644
index 000000000..def97bfb6
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module.ts
@@ -0,0 +1,20 @@
+import { Module } from '@nestjs/common';
+
+import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
+import { MicrosoftCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service';
+import { MicrosoftCalendarImportEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service';
+import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
+
+@Module({
+ imports: [EnvironmentModule],
+ providers: [
+ MicrosoftCalendarGetEventsService,
+ MicrosoftCalendarImportEventsService,
+ MicrosoftOAuth2ClientManagerService,
+ ],
+ exports: [
+ MicrosoftCalendarGetEventsService,
+ MicrosoftCalendarImportEventsService,
+ ],
+})
+export class MicrosoftCalendarDriverModule {}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts
new file mode 100644
index 000000000..ce9da8555
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts
@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+
+import {
+ PageCollection,
+ PageIterator,
+ PageIteratorCallback,
+} from '@microsoft/microsoft-graph-client';
+
+import { parseMicrosoftCalendarError } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util';
+import { GetCalendarEventsResponse } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
+import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
+import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
+
+@Injectable()
+export class MicrosoftCalendarGetEventsService {
+ constructor(
+ private readonly microsoftOAuth2ClientManagerService: MicrosoftOAuth2ClientManagerService,
+ ) {}
+
+ public async getCalendarEvents(
+ connectedAccount: Pick<
+ ConnectedAccountWorkspaceEntity,
+ 'provider' | 'refreshToken' | 'id'
+ >,
+ syncCursor?: string,
+ ): Promise {
+ try {
+ const microsoftClient =
+ await this.microsoftOAuth2ClientManagerService.getOAuth2Client(
+ connectedAccount.refreshToken,
+ );
+ const eventIds: string[] = [];
+
+ const response: PageCollection = await microsoftClient
+ .api(syncCursor || '/me/calendar/events/delta')
+ .version('beta')
+ .get();
+
+ const callback: PageIteratorCallback = (data) => {
+ eventIds.push(data.id);
+
+ return true;
+ };
+
+ const pageIterator = new PageIterator(
+ microsoftClient,
+ response,
+ callback,
+ );
+
+ await pageIterator.iterate();
+
+ return {
+ fullEvents: false,
+ calendarEventIds: eventIds,
+ nextSyncCursor: pageIterator.getDeltaLink() || '',
+ };
+ } catch (error) {
+ throw parseMicrosoftCalendarError(error);
+ }
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts
new file mode 100644
index 000000000..7f6ac5223
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts
@@ -0,0 +1,45 @@
+import { Injectable } from '@nestjs/common';
+
+import { Event } from '@microsoft/microsoft-graph-types';
+
+import { formatMicrosoftCalendarEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/format-microsoft-calendar-event.util';
+import { parseMicrosoftCalendarError } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util';
+import { CalendarEventWithParticipants } from 'src/modules/calendar/common/types/calendar-event';
+import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
+import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
+
+@Injectable()
+export class MicrosoftCalendarImportEventsService {
+ constructor(
+ private readonly microsoftOAuth2ClientManagerService: MicrosoftOAuth2ClientManagerService,
+ ) {}
+
+ public async getCalendarEvents(
+ connectedAccount: Pick<
+ ConnectedAccountWorkspaceEntity,
+ 'provider' | 'refreshToken' | 'id'
+ >,
+ changedEventIds: string[],
+ ): Promise {
+ try {
+ const microsoftClient =
+ await this.microsoftOAuth2ClientManagerService.getOAuth2Client(
+ connectedAccount.refreshToken,
+ );
+
+ const events: Event[] = [];
+
+ for (const changedEventId of changedEventIds) {
+ const event = await microsoftClient
+ .api(`/me/calendar/events/${changedEventId}`)
+ .get();
+
+ events.push(event);
+ }
+
+ return formatMicrosoftCalendarEvents(events);
+ } catch (error) {
+ throw parseMicrosoftCalendarError(error);
+ }
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/format-microsoft-calendar-event.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/format-microsoft-calendar-event.util.ts
new file mode 100644
index 000000000..a0085bff2
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/format-microsoft-calendar-event.util.ts
@@ -0,0 +1,60 @@
+import {
+ Event,
+ NullableOption,
+ ResponseType,
+} from '@microsoft/microsoft-graph-types';
+
+import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
+import { CalendarEventWithParticipants } from 'src/modules/calendar/common/types/calendar-event';
+
+export const formatMicrosoftCalendarEvents = (
+ events: Event[],
+): CalendarEventWithParticipants[] => {
+ return events.map(formatMicrosoftCalendarEvent);
+};
+
+const formatMicrosoftCalendarEvent = (
+ event: Event,
+): CalendarEventWithParticipants => {
+ const formatResponseStatus = (
+ status: NullableOption | undefined,
+ ) => {
+ switch (status) {
+ case 'accepted':
+ case 'organizer':
+ return CalendarEventParticipantResponseStatus.ACCEPTED;
+ case 'declined':
+ return CalendarEventParticipantResponseStatus.DECLINED;
+ case 'tentativelyAccepted':
+ return CalendarEventParticipantResponseStatus.TENTATIVE;
+ default:
+ return CalendarEventParticipantResponseStatus.NEEDS_ACTION;
+ }
+ };
+
+ return {
+ title: event.subject ?? '',
+ isCanceled: !!event.isCancelled,
+ isFullDay: !!event.isAllDay,
+ startsAt: event.start?.dateTime ?? null,
+ endsAt: event.end?.dateTime ?? null,
+ externalId: event.id ?? '',
+ externalCreatedAt: event.createdDateTime ?? null,
+ externalUpdatedAt: event.lastModifiedDateTime ?? null,
+ description: event.body?.content ?? '',
+ location: event.location?.displayName ?? '',
+ iCalUID: event.iCalUId ?? '',
+ conferenceSolution: event.onlineMeetingProvider ?? '',
+ conferenceLinkLabel: event.onlineMeeting?.joinUrl ?? '',
+ conferenceLinkUrl: event.onlineMeeting?.joinUrl ?? '',
+ recurringEventExternalId: event.id ?? '',
+ participants:
+ event.attendees?.map((attendee) => ({
+ handle: attendee.emailAddress?.address ?? '',
+ displayName: attendee.emailAddress?.name ?? '',
+ isOrganizer: attendee.status?.response === 'organizer',
+ responseStatus: formatResponseStatus(attendee.status?.response),
+ })) ?? [],
+ status: '',
+ };
+};
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util.ts
new file mode 100644
index 000000000..78ed9024b
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util.ts
@@ -0,0 +1,65 @@
+import { GraphError } from '@microsoft/microsoft-graph-client';
+
+import {
+ CalendarEventImportDriverException,
+ CalendarEventImportDriverExceptionCode,
+} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
+
+export const parseMicrosoftCalendarError = (
+ error: GraphError,
+): CalendarEventImportDriverException => {
+ const { statusCode, message } = error;
+
+ switch (statusCode) {
+ case 400:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.UNKNOWN,
+ );
+
+ case 404:
+ if (
+ message ==
+ 'The mailbox is either inactive, soft-deleted, or is hosted on-premise.'
+ ) {
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
+ );
+ }
+
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.NOT_FOUND,
+ );
+
+ case 429:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
+ );
+
+ case 403:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
+ );
+
+ case 401:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
+ );
+ case 500:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.UNKNOWN,
+ );
+
+ default:
+ return new CalendarEventImportDriverException(
+ message,
+ CalendarEventImportDriverExceptionCode.UNKNOWN,
+ );
+ }
+};
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job.ts
index 1e28927e2..30fdda2f6 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job.ts
@@ -4,14 +4,14 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
-import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
+import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
import {
CalendarChannelSyncStage,
CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
-export type CalendarEventsImportJobData = {
+export type CalendarEventListFetchJobData = {
calendarChannelId: string;
workspaceId: string;
};
@@ -23,11 +23,11 @@ export type CalendarEventsImportJobData = {
export class CalendarEventListFetchJob {
constructor(
private readonly twentyORMManager: TwentyORMManager,
- private readonly calendarEventsImportService: CalendarEventsImportService,
+ private readonly calendarFetchEventsService: CalendarFetchEventsService,
) {}
@Process(CalendarEventListFetchJob.name)
- async handle(data: CalendarEventsImportJobData): Promise {
+ async handle(data: CalendarEventListFetchJobData): Promise {
console.time('CalendarEventListFetchJob time');
const { workspaceId, calendarChannelId } = data;
@@ -65,7 +65,7 @@ export class CalendarEventListFetchJob {
syncStageStartedAt: null,
});
- await this.calendarEventsImportService.processCalendarEventsImport(
+ await this.calendarFetchEventsService.fetchCalendarEvents(
calendarChannel,
calendarChannel.connectedAccount,
workspaceId,
@@ -73,7 +73,7 @@ export class CalendarEventListFetchJob {
break;
case CalendarChannelSyncStage.PARTIAL_CALENDAR_EVENT_LIST_FETCH_PENDING:
- await this.calendarEventsImportService.processCalendarEventsImport(
+ await this.calendarFetchEventsService.fetchCalendarEvents(
calendarChannel,
calendarChannel.connectedAccount,
workspaceId,
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job.ts
new file mode 100644
index 000000000..d99607501
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job.ts
@@ -0,0 +1,75 @@
+import { Scope } from '@nestjs/common';
+
+import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
+import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
+import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
+import {
+ CalendarChannelSyncStage,
+ CalendarChannelWorkspaceEntity,
+} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
+
+export type CalendarEventsImportJobData = {
+ calendarChannelId: string;
+ workspaceId: string;
+};
+
+@Processor({
+ queueName: MessageQueue.calendarQueue,
+ scope: Scope.REQUEST,
+})
+export class CalendarEventsImportJob {
+ constructor(
+ private readonly calendarEventsImportService: CalendarEventsImportService,
+ private readonly twentyORMManager: TwentyORMManager,
+ ) {}
+
+ @Process(CalendarEventsImportJob.name)
+ async handle(data: CalendarEventsImportJobData): Promise {
+ console.time('CalendarEventsImportJob time');
+
+ const { calendarChannelId, workspaceId } = data;
+
+ const calendarChannelRepository =
+ await this.twentyORMManager.getRepository(
+ 'calendarChannel',
+ );
+ const calendarChannel = await calendarChannelRepository.findOne({
+ where: {
+ id: calendarChannelId,
+ isSyncEnabled: true,
+ },
+ relations: ['connectedAccount'],
+ });
+
+ if (!calendarChannel?.isSyncEnabled) {
+ return;
+ }
+
+ if (
+ isThrottled(
+ calendarChannel.syncStageStartedAt,
+ calendarChannel.throttleFailureCount,
+ )
+ ) {
+ return;
+ }
+
+ if (
+ calendarChannel.syncStage !==
+ CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING
+ ) {
+ return;
+ }
+
+ await this.calendarEventsImportService.processCalendarEventsImport(
+ calendarChannel,
+ calendarChannel.connectedAccount,
+ workspaceId,
+ );
+
+ console.timeEnd('CalendarEventsImportJob time');
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts
index 0dd215b88..826a73a82 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts
@@ -2,84 +2,86 @@ import { Injectable } from '@nestjs/common';
import { Any } from 'typeorm';
+import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
+import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
+import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { BlocklistRepository } from 'src/modules/blocklist/repositories/blocklist.repository';
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarEventCleanerService } from 'src/modules/calendar/calendar-event-cleaner/services/calendar-event-cleaner.service';
+import { CALENDAR_EVENT_IMPORT_BATCH_SIZE } from 'src/modules/calendar/calendar-event-import-manager/constants/calendar-event-import-batch-size';
+import { MicrosoftCalendarImportEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service';
import {
CalendarEventImportErrorHandlerService,
CalendarEventImportSyncStep,
} from 'src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service';
-import {
- CalendarGetCalendarEventsService,
- GetCalendarEventsResponse,
-} from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
import { filterEventsAndReturnCancelledEvents } from 'src/modules/calendar/calendar-event-import-manager/utils/filter-events.util';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
-import {
- CalendarChannelSyncStage,
- CalendarChannelWorkspaceEntity,
-} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { CalendarEventWithParticipants } from 'src/modules/calendar/common/types/calendar-event';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class CalendarEventsImportService {
constructor(
+ @InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
+ private readonly cacheStorage: CacheStorageService,
private readonly twentyORMManager: TwentyORMManager,
@InjectObjectMetadataRepository(BlocklistWorkspaceEntity)
private readonly blocklistRepository: BlocklistRepository,
private readonly calendarEventCleanerService: CalendarEventCleanerService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
- private readonly getCalendarEventsService: CalendarGetCalendarEventsService,
private readonly calendarSaveEventsService: CalendarSaveEventsService,
private readonly calendarEventImportErrorHandlerService: CalendarEventImportErrorHandlerService,
+ private readonly microsoftCalendarImportEventService: MicrosoftCalendarImportEventsService,
) {}
public async processCalendarEventsImport(
calendarChannel: CalendarChannelWorkspaceEntity,
connectedAccount: ConnectedAccountWorkspaceEntity,
workspaceId: string,
+ fetchedCalendarEvents?: CalendarEventWithParticipants[],
): Promise {
- const syncStep =
- calendarChannel.syncStage ===
- CalendarChannelSyncStage.FULL_CALENDAR_EVENT_LIST_FETCH_PENDING
- ? CalendarEventImportSyncStep.FULL_CALENDAR_EVENT_LIST_FETCH
- : CalendarEventImportSyncStep.PARTIAL_CALENDAR_EVENT_LIST_FETCH;
-
- await this.calendarChannelSyncStatusService.markAsCalendarEventListFetchOngoing(
+ await this.calendarChannelSyncStatusService.markAsCalendarEventsImportOngoing(
[calendarChannel.id],
);
- let calendarEvents: GetCalendarEventsResponse['calendarEvents'] = [];
- let nextSyncCursor: GetCalendarEventsResponse['nextSyncCursor'] = '';
+
+ let calendarEvents: CalendarEventWithParticipants[] = [];
try {
- const getCalendarEventsResponse =
- await this.getCalendarEventsService.getCalendarEvents(
- connectedAccount,
- calendarChannel.syncCursor,
+ if (fetchedCalendarEvents) {
+ calendarEvents = fetchedCalendarEvents;
+ } else {
+ const eventIdsToFetch: string[] = await this.cacheStorage.setPop(
+ `calendar-events-to-import:${workspaceId}:${calendarChannel.id}`,
+ CALENDAR_EVENT_IMPORT_BATCH_SIZE,
);
- calendarEvents = getCalendarEventsResponse.calendarEvents;
- nextSyncCursor = getCalendarEventsResponse.nextSyncCursor;
+ if (!eventIdsToFetch || eventIdsToFetch.length === 0) {
+ await this.calendarChannelSyncStatusService.markAsCompletedAndSchedulePartialCalendarEventListFetch(
+ [calendarChannel.id],
+ );
- const calendarChannelRepository =
- await this.twentyORMManager.getRepository(
- 'calendarChannel',
- );
+ return;
+ }
+
+ switch (connectedAccount.provider) {
+ case 'microsoft':
+ calendarEvents =
+ await this.microsoftCalendarImportEventService.getCalendarEvents(
+ connectedAccount,
+ eventIdsToFetch,
+ );
+ break;
+ default:
+ break;
+ }
+ }
if (!calendarEvents || calendarEvents?.length === 0) {
- await calendarChannelRepository.update(
- {
- id: calendarChannel.id,
- },
- {
- syncCursor: nextSyncCursor,
- },
- );
-
await this.calendarChannelSyncStatusService.schedulePartialCalendarEventListFetch(
[calendarChannel.id],
);
@@ -127,22 +129,13 @@ export class CalendarEventsImportService {
workspaceId,
);
- await calendarChannelRepository.update(
- {
- id: calendarChannel.id,
- },
- {
- syncCursor: nextSyncCursor,
- },
- );
-
await this.calendarChannelSyncStatusService.markAsCompletedAndSchedulePartialCalendarEventListFetch(
[calendarChannel.id],
);
} catch (error) {
await this.calendarEventImportErrorHandlerService.handleDriverException(
error,
- syncStep,
+ CalendarEventImportSyncStep.CALENDAR_EVENTS_IMPORT,
calendarChannel,
workspaceId,
);
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service.ts
new file mode 100644
index 000000000..088530237
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service.ts
@@ -0,0 +1,129 @@
+import { Injectable } from '@nestjs/common';
+
+import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
+import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
+import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
+import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
+import {
+ CalendarEventImportDriverException,
+ CalendarEventImportDriverExceptionCode,
+} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
+import {
+ CalendarEventImportErrorHandlerService,
+ CalendarEventImportSyncStep,
+} from 'src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service';
+import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
+import { CalendarGetCalendarEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
+import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
+import {
+ CalendarChannelSyncStage,
+ CalendarChannelWorkspaceEntity,
+} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
+import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
+
+@Injectable()
+export class CalendarFetchEventsService {
+ constructor(
+ @InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
+ private readonly cacheStorage: CacheStorageService,
+ private readonly twentyORMManager: TwentyORMManager,
+ private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
+ private readonly getCalendarEventsService: CalendarGetCalendarEventsService,
+ private readonly calendarEventImportErrorHandlerService: CalendarEventImportErrorHandlerService,
+ private readonly calendarEventsImportService: CalendarEventsImportService,
+ ) {}
+
+ public async fetchCalendarEvents(
+ calendarChannel: CalendarChannelWorkspaceEntity,
+ connectedAccount: ConnectedAccountWorkspaceEntity,
+ workspaceId: string,
+ ): Promise {
+ const syncStep =
+ calendarChannel.syncStage ===
+ CalendarChannelSyncStage.FULL_CALENDAR_EVENT_LIST_FETCH_PENDING
+ ? CalendarEventImportSyncStep.FULL_CALENDAR_EVENT_LIST_FETCH
+ : CalendarEventImportSyncStep.PARTIAL_CALENDAR_EVENT_LIST_FETCH;
+
+ await this.calendarChannelSyncStatusService.markAsCalendarEventListFetchOngoing(
+ [calendarChannel.id],
+ );
+
+ try {
+ const getCalendarEventsResponse =
+ await this.getCalendarEventsService.getCalendarEvents(
+ connectedAccount,
+ calendarChannel.syncCursor,
+ );
+
+ const hasFullEvents = getCalendarEventsResponse.fullEvents;
+
+ const calendarEvents = hasFullEvents
+ ? getCalendarEventsResponse.calendarEvents
+ : null;
+ const calendarEventIds = getCalendarEventsResponse.calendarEventIds;
+ const nextSyncCursor = getCalendarEventsResponse.nextSyncCursor;
+
+ const calendarChannelRepository =
+ await this.twentyORMManager.getRepository(
+ 'calendarChannel',
+ );
+
+ if (!calendarEvents || calendarEvents?.length === 0) {
+ await calendarChannelRepository.update(
+ {
+ id: calendarChannel.id,
+ },
+ {
+ syncCursor: nextSyncCursor,
+ },
+ );
+
+ await this.calendarChannelSyncStatusService.schedulePartialCalendarEventListFetch(
+ [calendarChannel.id],
+ );
+ }
+
+ await calendarChannelRepository.update(
+ {
+ id: calendarChannel.id,
+ },
+ {
+ syncCursor: nextSyncCursor,
+ },
+ );
+
+ if (hasFullEvents && calendarEvents) {
+ // Event Import already done
+ await this.calendarEventsImportService.processCalendarEventsImport(
+ calendarChannel,
+ connectedAccount,
+ workspaceId,
+ calendarEvents,
+ );
+ } else if (!hasFullEvents && calendarEventIds) {
+ // Event Import still needed
+
+ await this.cacheStorage.setAdd(
+ `calendar-events-to-import:${workspaceId}:${calendarChannel.id}`,
+ calendarEventIds,
+ );
+
+ await this.calendarChannelSyncStatusService.scheduleCalendarEventsImport(
+ [calendarChannel.id],
+ );
+ } else {
+ throw new CalendarEventImportDriverException(
+ "Expected 'calendarEvents' or 'calendarEventIds' to be present",
+ CalendarEventImportDriverExceptionCode.UNKNOWN,
+ );
+ }
+ } catch (error) {
+ await this.calendarEventImportErrorHandlerService.handleDriverException(
+ error,
+ syncStep,
+ calendarChannel,
+ workspaceId,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts
index 8bee58bd4..6704fa355 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
-import { GoogleCalendarGetEventsService as GoogleCalendarGetCalendarEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service';
+import { GoogleCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service';
+import { MicrosoftCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service';
import {
CalendarEventImportException,
CalendarEventImportExceptionCode,
@@ -9,14 +10,17 @@ import { CalendarEventWithParticipants } from 'src/modules/calendar/common/types
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type GetCalendarEventsResponse = {
- calendarEvents: CalendarEventWithParticipants[];
+ fullEvents: boolean;
+ calendarEvents?: CalendarEventWithParticipants[];
+ calendarEventIds?: string[];
nextSyncCursor: string;
};
@Injectable()
export class CalendarGetCalendarEventsService {
constructor(
- private readonly googleCalendarGetCalendarEventsService: GoogleCalendarGetCalendarEventsService,
+ private readonly googleCalendarGetEventsService: GoogleCalendarGetEventsService,
+ private readonly microsoftCalendarGetEventsService: MicrosoftCalendarGetEventsService,
) {}
public async getCalendarEvents(
@@ -28,7 +32,12 @@ export class CalendarGetCalendarEventsService {
): Promise {
switch (connectedAccount.provider) {
case 'google':
- return this.googleCalendarGetCalendarEventsService.getCalendarEvents(
+ return this.googleCalendarGetEventsService.getCalendarEvents(
+ connectedAccount,
+ syncCursor,
+ );
+ case 'microsoft':
+ return this.microsoftCalendarGetEventsService.getCalendarEvents(
connectedAccount,
syncCursor,
);
diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/utils/filter-events.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/utils/filter-events.util.ts
index 2cc28f3a0..d705f81c0 100644
--- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/utils/filter-events.util.ts
+++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/utils/filter-events.util.ts
@@ -23,7 +23,7 @@ export const filterEventsAndReturnCancelledEvents = (
},
event,
) => {
- if (event.status === 'cancelled') {
+ if (event.isCanceled) {
acc.cancelledEvents.push(event);
} else {
acc.filteredEvents.push(event);
diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts
new file mode 100644
index 000000000..b14bb1f82
--- /dev/null
+++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts
@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+
+import {
+ AuthProvider,
+ AuthProviderCallback,
+ Client,
+} from '@microsoft/microsoft-graph-client';
+
+import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+
+@Injectable()
+export class MicrosoftOAuth2ClientManagerService {
+ constructor(private readonly environmentService: EnvironmentService) {}
+
+ public async getOAuth2Client(refreshToken: string): Promise {
+ const authProvider: AuthProvider = async (
+ callback: AuthProviderCallback,
+ ) => {
+ try {
+ const tenantId = this.environmentService.get(
+ 'AUTH_MICROSOFT_TENANT_ID',
+ );
+
+ const urlData = new URLSearchParams();
+
+ urlData.append(
+ 'client_id',
+ this.environmentService.get('AUTH_MICROSOFT_CLIENT_ID'),
+ );
+ urlData.append('scope', 'https://graph.microsoft.com/.default');
+ urlData.append('refresh_token', refreshToken);
+ urlData.append(
+ 'client_secret',
+ this.environmentService.get('AUTH_MICROSOFT_CLIENT_SECRET'),
+ );
+ urlData.append('grant_type', 'refresh_token');
+
+ const res = await fetch(
+ `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
+ {
+ method: 'POST',
+ body: urlData,
+ },
+ );
+
+ const data = await res.json();
+
+ callback(null, data.access_token);
+ } catch (error) {
+ callback(error, null);
+ }
+ };
+
+ const client = Client.init({
+ defaultVersion: 'v1.0',
+ debugLogging: false,
+ authProvider: authProvider,
+ });
+
+ return client;
+ }
+}
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
index 23c65eba1..c70acc33f 100644
--- 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
@@ -1,11 +1,16 @@
import { Module } from '@nestjs/common';
import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service';
+import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-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],
+ providers: [
+ OAuth2ClientManagerService,
+ GoogleOAuth2ClientManagerService,
+ MicrosoftOAuth2ClientManagerService,
+ ],
+ exports: [OAuth2ClientManagerService, MicrosoftOAuth2ClientManagerService],
})
export class OAuth2ClientManagerModule {}
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 cef7d476c..b1c0c97c1 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
@@ -22,6 +22,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
export enum ConnectedAccountProvider {
GOOGLE = 'google',
+ MICROSOFT = 'microsoft',
}
@WorkspaceEntity({
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts
index eca12dc5d..c6bf2e68b 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts
@@ -35,6 +35,12 @@ export class MessagingGetMessageListService {
return this.gmailGetMessageListService.getFullMessageList(
connectedAccount,
);
+ case 'microsoft':
+ // TODO: Placeholder
+ return {
+ messageExternalIds: [],
+ nextSyncCursor: '',
+ };
default:
throw new MessageImportException(
`Provider ${connectedAccount.provider} is not supported`,
@@ -56,6 +62,12 @@ export class MessagingGetMessageListService {
connectedAccount,
syncCursor,
);
+ case 'microsoft':
+ return {
+ messageExternalIds: [],
+ messageExternalIdsToDelete: [],
+ nextSyncCursor: '',
+ };
default:
throw new MessageImportException(
`Provider ${connectedAccount.provider} is not supported`,
diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx
index 9c5406625..a4657acf5 100644
--- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx
+++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx
@@ -75,6 +75,7 @@ yarn command:prod cron:calendar:calendar-event-list-fetch
['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'],
['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'],
['AUTH_MICROSOFT_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft/redirect', 'Microsoft auth callback'],
+ ['AUTH_GOOGLE_APIS_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft-apis/get-access-token', 'Microsoft APIs auth callback'],
['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'],
['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'],
['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'],
diff --git a/yarn.lock b/yarn.lock
index 8490802e9..289dd0c56 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6682,6 +6682,32 @@ __metadata:
languageName: node
linkType: hard
+"@microsoft/microsoft-graph-client@npm:^3.0.7":
+ version: 3.0.7
+ resolution: "@microsoft/microsoft-graph-client@npm:3.0.7"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ tslib: "npm:^2.2.0"
+ peerDependenciesMeta:
+ "@azure/identity":
+ optional: true
+ "@azure/msal-browser":
+ optional: true
+ buffer:
+ optional: true
+ stream-browserify:
+ optional: true
+ checksum: 10c0/0e5b3dd469be0606ba34a82464d18a8c919f396adf65756612e9ec0bb47c99c4c3eaf5d65eec953e2c6f5b79e48e003efe914adbfc8b741d098e8e8f7084ea8b
+ languageName: node
+ linkType: hard
+
+"@microsoft/microsoft-graph-types@npm:^2.40.0":
+ version: 2.40.0
+ resolution: "@microsoft/microsoft-graph-types@npm:2.40.0"
+ checksum: 10c0/c6f69a0fe136579d735efafc1375e4540e01d34ea488d987a9b651f7206c40c76991fff56df31e8b2593f16846bd748d0ffcf249d1f2629bb0298e6214fe2fdc
+ languageName: node
+ linkType: hard
+
"@microsoft/tsdoc-config@npm:~0.16.1":
version: 0.16.2
resolution: "@microsoft/tsdoc-config@npm:0.16.2"
@@ -44331,6 +44357,8 @@ __metadata:
"@linaria/core": "npm:^6.2.0"
"@linaria/react": "npm:^6.2.1"
"@mdx-js/react": "npm:^3.0.0"
+ "@microsoft/microsoft-graph-client": "npm:^3.0.7"
+ "@microsoft/microsoft-graph-types": "npm:^2.40.0"
"@nestjs/apollo": "npm:^11.0.5"
"@nestjs/axios": "npm:^3.0.1"
"@nestjs/cli": "npm:^9.0.0"