feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)

## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
Charles Bochet
2026-03-20 17:22:22 +01:00
committed by GitHub
parent a8625d8bfb
commit 9cb21e71fa
88 changed files with 3412 additions and 1233 deletions
@@ -770,7 +770,6 @@ export type CalendarChannel = {
id: Scalars['UUID'];
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
syncCursor?: Maybe<Scalars['String']>;
syncStage: CalendarChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: CalendarChannelSyncStatus;
@@ -979,18 +978,14 @@ export type ConfigVariablesGroupData = {
export type ConnectedAccountDto = {
__typename?: 'ConnectedAccountDTO';
accessToken?: Maybe<Scalars['String']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<Scalars['JSON']>;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
handleAliases?: Maybe<Array<Scalars['String']>>;
id: Scalars['UUID'];
lastCredentialsRefreshedAt?: Maybe<Scalars['DateTime']>;
lastSignedInAt?: Maybe<Scalars['DateTime']>;
oidcTokenClaims?: Maybe<Scalars['JSON']>;
provider: Scalars['String'];
refreshToken?: Maybe<Scalars['String']>;
scopes?: Maybe<Array<Scalars['String']>>;
updatedAt: Scalars['DateTime'];
userWorkspaceId: Scalars['UUID'];
@@ -1077,17 +1072,6 @@ export type CreateApprovedAccessDomainInput = {
email: Scalars['String'];
};
export type CreateCalendarChannelInput = {
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
syncStage: CalendarChannelSyncStage;
visibility: CalendarChannelVisibility;
};
export type CreateCommandMenuItemInput = {
availabilityObjectMetadataId?: InputMaybe<Scalars['UUID']>;
availabilityType?: InputMaybe<CommandMenuItemAvailabilityType>;
@@ -1103,16 +1087,6 @@ export type CreateCommandMenuItemInput = {
workflowVersionId?: InputMaybe<Scalars['UUID']>;
};
export type CreateConnectedAccountInput = {
accessToken?: InputMaybe<Scalars['String']>;
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
provider: Scalars['String'];
refreshToken?: InputMaybe<Scalars['String']>;
scopes?: InputMaybe<Array<Scalars['String']>>;
userWorkspaceId: Scalars['UUID'];
};
export type CreateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -1159,33 +1133,6 @@ export type CreateLogicFunctionFromSourceInput = {
universalIdentifier?: InputMaybe<Scalars['UUID']>;
};
export type CreateMessageChannelInput = {
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
excludeGroupEmails: Scalars['Boolean'];
excludeNonProfessionalEmails: Scalars['Boolean'];
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
messageFolderImportPolicy: MessageFolderImportPolicy;
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
syncStage: MessageChannelSyncStage;
type: MessageChannelType;
visibility: MessageChannelVisibility;
};
export type CreateMessageFolderInput = {
externalId?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['UUID']>;
isSentFolder: Scalars['Boolean'];
isSynced: Scalars['Boolean'];
messageChannelId: Scalars['UUID'];
name?: InputMaybe<Scalars['String']>;
parentFolderId?: InputMaybe<Scalars['UUID']>;
pendingSyncAction: MessageFolderPendingSyncAction;
};
export type CreateNavigationMenuItemInput = {
color?: InputMaybe<Scalars['String']>;
folderId?: InputMaybe<Scalars['UUID']>;
@@ -2421,7 +2368,6 @@ export type MessageChannel = {
isSyncEnabled: Scalars['Boolean'];
messageFolderImportPolicy: MessageFolderImportPolicy;
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
syncCursor?: Maybe<Scalars['String']>;
syncStage: MessageChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: MessageChannelSyncStatus;
@@ -2486,7 +2432,6 @@ export type MessageFolder = {
name?: Maybe<Scalars['String']>;
parentFolderId?: Maybe<Scalars['UUID']>;
pendingSyncAction: MessageFolderPendingSyncAction;
syncCursor?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
};
@@ -2572,10 +2517,8 @@ export type Mutation = {
createApplicationRegistration: CreateApplicationRegistration;
createApplicationRegistrationVariable: ApplicationRegistrationVariable;
createApprovedAccessDomain: ApprovedAccessDomain;
createCalendarChannel: CalendarChannel;
createChatThread: AgentChatThread;
createCommandMenuItem: CommandMenuItem;
createConnectedAccount: ConnectedAccountDto;
createDatabaseConfigVariable: Scalars['Boolean'];
createDevelopmentApplication: DevelopmentApplication;
createEmailingDomain: EmailingDomain;
@@ -2583,8 +2526,6 @@ export type Mutation = {
createManyViewFieldGroups: Array<ViewFieldGroup>;
createManyViewFields: Array<ViewField>;
createManyViewGroups: Array<ViewGroup>;
createMessageChannel: MessageChannel;
createMessageFolder: MessageFolder;
createNavigationMenuItem: NavigationMenuItem;
createOIDCIdentityProvider: SetupSso;
createObjectEvent: Analytics;
@@ -2612,7 +2553,6 @@ export type Mutation = {
deleteApplicationRegistration: Scalars['Boolean'];
deleteApplicationRegistrationVariable: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCalendarChannel: CalendarChannel;
deleteCommandMenuItem: CommandMenuItem;
deleteConnectedAccount: ConnectedAccountDto;
deleteCurrentWorkspace: Workspace;
@@ -2620,8 +2560,6 @@ export type Mutation = {
deleteEmailingDomain: Scalars['Boolean'];
deleteFrontComponent: FrontComponent;
deleteJobs: DeleteJobsResponse;
deleteMessageChannel: MessageChannel;
deleteMessageFolder: MessageFolder;
deleteNavigationMenuItem: NavigationMenuItem;
deleteOneAgent: Agent;
deleteOneField: Field;
@@ -2708,12 +2646,12 @@ export type Mutation = {
updateApplicationRegistrationVariable: ApplicationRegistrationVariable;
updateCalendarChannel: CalendarChannel;
updateCommandMenuItem: CommandMenuItem;
updateConnectedAccount: ConnectedAccountDto;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateFrontComponent: FrontComponent;
updateLabPublicFeatureFlag: FeatureFlag;
updateMessageChannel: MessageChannel;
updateMessageFolder: MessageFolder;
updateMessageFolders: Array<MessageFolder>;
updateNavigationMenuItem: NavigationMenuItem;
updateOneAgent: Agent;
updateOneApplicationVariable: Scalars['Boolean'];
@@ -2831,21 +2769,11 @@ export type MutationCreateApprovedAccessDomainArgs = {
};
export type MutationCreateCalendarChannelArgs = {
input: CreateCalendarChannelInput;
};
export type MutationCreateCommandMenuItemArgs = {
input: CreateCommandMenuItemInput;
};
export type MutationCreateConnectedAccountArgs = {
input: CreateConnectedAccountInput;
};
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
@@ -2884,16 +2812,6 @@ export type MutationCreateManyViewGroupsArgs = {
};
export type MutationCreateMessageChannelArgs = {
input: CreateMessageChannelInput;
};
export type MutationCreateMessageFolderArgs = {
input: CreateMessageFolderInput;
};
export type MutationCreateNavigationMenuItemArgs = {
input: CreateNavigationMenuItemInput;
};
@@ -3032,11 +2950,6 @@ export type MutationDeleteApprovedAccessDomainArgs = {
};
export type MutationDeleteCalendarChannelArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteCommandMenuItemArgs = {
id: Scalars['UUID'];
};
@@ -3068,16 +2981,6 @@ export type MutationDeleteJobsArgs = {
};
export type MutationDeleteMessageChannelArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteMessageFolderArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteNavigationMenuItemArgs = {
id: Scalars['UUID'];
};
@@ -3490,11 +3393,6 @@ export type MutationUpdateCommandMenuItemArgs = {
};
export type MutationUpdateConnectedAccountArgs = {
input: UpdateConnectedAccountInput;
};
export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
@@ -3521,6 +3419,11 @@ export type MutationUpdateMessageFolderArgs = {
};
export type MutationUpdateMessageFoldersArgs = {
input: UpdateMessageFoldersInput;
};
export type MutationUpdateNavigationMenuItemArgs = {
input: UpdateOneNavigationMenuItemInput;
};
@@ -4222,8 +4125,6 @@ export type Query = {
applicationRegistrationTarballUrl?: Maybe<Scalars['String']>;
barChartData: BarChartData;
billingPortalSession: BillingSession;
calendarChannel?: Maybe<CalendarChannel>;
calendarChannels: Array<CalendarChannel>;
chatMessages: Array<AgentMessage>;
chatThread: AgentChatThread;
chatThreads: AgentChatThreadConnection;
@@ -4231,7 +4132,6 @@ export type Query = {
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
commandMenuItem?: Maybe<CommandMenuItem>;
commandMenuItems: Array<CommandMenuItem>;
connectedAccount?: Maybe<ConnectedAccountDto>;
connectedAccounts: Array<ConnectedAccountDto>;
currentUser: User;
currentWorkspace: Workspace;
@@ -4307,11 +4207,11 @@ export type Query = {
indexMetadatas: IndexConnection;
lineChartData: LineChartData;
listPlans: Array<BillingPlan>;
messageChannel?: Maybe<MessageChannel>;
messageChannels: Array<MessageChannel>;
messageFolder?: Maybe<MessageFolder>;
messageFolders: Array<MessageFolder>;
minimalMetadata: MinimalMetadata;
myCalendarChannels: Array<CalendarChannel>;
myConnectedAccounts: Array<ConnectedAccountDto>;
myMessageChannels: Array<MessageChannel>;
myMessageFolders: Array<MessageFolder>;
navigationMenuItem?: Maybe<NavigationMenuItem>;
navigationMenuItems: Array<NavigationMenuItem>;
object: Object;
@@ -4352,16 +4252,6 @@ export type QueryBillingPortalSessionArgs = {
};
export type QueryCalendarChannelArgs = {
id: Scalars['UUID'];
};
export type QueryCalendarChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryChatMessagesArgs = {
threadId: Scalars['UUID'];
};
@@ -4395,11 +4285,6 @@ export type QueryCommandMenuItemArgs = {
};
export type QueryConnectedAccountArgs = {
id: Scalars['UUID'];
};
export type QueryEnterpriseCheckoutSessionArgs = {
billingInterval?: InputMaybe<Scalars['String']>;
};
@@ -4663,22 +4548,17 @@ export type QueryLineChartDataArgs = {
};
export type QueryMessageChannelArgs = {
id: Scalars['UUID'];
};
export type QueryMessageChannelsArgs = {
export type QueryMyCalendarChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryMessageFolderArgs = {
id: Scalars['UUID'];
export type QueryMyMessageChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryMessageFoldersArgs = {
export type QueryMyMessageFoldersArgs = {
messageChannelId?: InputMaybe<Scalars['UUID']>;
};
@@ -5222,18 +5102,6 @@ export type UpdateCommandMenuItemInput = {
shortLabel?: InputMaybe<Scalars['String']>;
};
export type UpdateConnectedAccountInput = {
id: Scalars['UUID'];
update: UpdateConnectedAccountInputUpdates;
};
export type UpdateConnectedAccountInputUpdates = {
accessToken?: InputMaybe<Scalars['String']>;
handleAliases?: InputMaybe<Array<Scalars['String']>>;
refreshToken?: InputMaybe<Scalars['String']>;
scopes?: InputMaybe<Array<Scalars['String']>>;
};
export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -5312,9 +5180,11 @@ export type UpdateMessageFolderInput = {
export type UpdateMessageFolderInputUpdates = {
isSynced?: InputMaybe<Scalars['Boolean']>;
name?: InputMaybe<Scalars['String']>;
pendingSyncAction?: InputMaybe<MessageFolderPendingSyncAction>;
syncCursor?: InputMaybe<Scalars['String']>;
};
export type UpdateMessageFoldersInput = {
ids: Array<Scalars['UUID']>;
update: UpdateMessageFolderInputUpdates;
};
export type UpdateNavigationMenuItemInput = {
@@ -6954,6 +6824,13 @@ export type PieChartDataQueryVariables = Exact<{
export type PieChartDataQuery = { __typename?: 'Query', pieChartData: { __typename?: 'PieChartData', showLegend: boolean, showDataLabels: boolean, showCenterMetric: boolean, hasTooManyGroups: boolean, formattedToRawLookup: any, data: Array<{ __typename?: 'PieChartDataItem', id: string, value: number }> } };
export type DeleteConnectedAccountMutationVariables = Exact<{
id: Scalars['UUID'];
}>;
export type DeleteConnectedAccountMutation = { __typename?: 'Mutation', deleteConnectedAccount: { __typename?: 'ConnectedAccountDTO', id: string } };
export type SaveImapSmtpCaldavAccountMutationVariables = Exact<{
accountOwnerId: Scalars['UUID'];
handle: Scalars['String'];
@@ -6971,6 +6848,34 @@ export type StartChannelSyncMutationVariables = Exact<{
export type StartChannelSyncMutation = { __typename?: 'Mutation', startChannelSync: { __typename?: 'ChannelSyncSuccess', success: boolean } };
export type UpdateCalendarChannelMutationVariables = Exact<{
input: UpdateCalendarChannelInput;
}>;
export type UpdateCalendarChannelMutation = { __typename?: 'Mutation', updateCalendarChannel: { __typename?: 'CalendarChannel', id: string, visibility: CalendarChannelVisibility, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy } };
export type UpdateMessageChannelMutationVariables = Exact<{
input: UpdateMessageChannelInput;
}>;
export type UpdateMessageChannelMutation = { __typename?: 'Mutation', updateMessageChannel: { __typename?: 'MessageChannel', id: string, visibility: MessageChannelVisibility, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, messageFolderImportPolicy: MessageFolderImportPolicy } };
export type UpdateMessageFolderMutationVariables = Exact<{
input: UpdateMessageFolderInput;
}>;
export type UpdateMessageFolderMutation = { __typename?: 'Mutation', updateMessageFolder: { __typename?: 'MessageFolder', id: string, isSynced: boolean } };
export type UpdateMessageFoldersMutationVariables = Exact<{
input: UpdateMessageFoldersInput;
}>;
export type UpdateMessageFoldersMutation = { __typename?: 'Mutation', updateMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, isSynced: boolean }> };
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
@@ -6978,6 +6883,32 @@ export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
export type GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, port: number, secure?: boolean | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null } };
export type MyCalendarChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyCalendarChannelsQuery = { __typename?: 'Query', myCalendarChannels: Array<{ __typename?: 'CalendarChannel', id: string, handle: string, visibility: CalendarChannelVisibility, syncStatus: CalendarChannelSyncStatus, syncStage: CalendarChannelSyncStage, syncStageStartedAt?: string | null, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy, isSyncEnabled: boolean, connectedAccountId: string, createdAt: string, updatedAt: string }> };
export type MyConnectedAccountsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, createdAt: string, updatedAt: string }> };
export type MyMessageChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyMessageChannelsQuery = { __typename?: 'Query', myMessageChannels: Array<{ __typename?: 'MessageChannel', id: string, handle: string, visibility: MessageChannelVisibility, type: MessageChannelType, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, messageFolderImportPolicy: MessageFolderImportPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, isSyncEnabled: boolean, syncStatus: MessageChannelSyncStatus, syncStage: MessageChannelSyncStage, syncStageStartedAt?: string | null, connectedAccountId: string, createdAt: string, updatedAt: string }> };
export type MyMessageFoldersQueryVariables = Exact<{
messageChannelId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyMessageFoldersQuery = { __typename?: 'Query', myMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, name?: string | null, isSynced: boolean, isSentFolder: boolean, parentFolderId?: string | null, externalId?: string | null, messageChannelId: string, createdAt: string, updatedAt: string }> };
export type SetAdminAiModelEnabledMutationVariables = Exact<{
modelId: Scalars['String'];
enabled: Scalars['Boolean'];
@@ -8079,9 +8010,18 @@ export const FindAllRecordPageLayoutsDocument = {"kind":"Document","definitions"
export const BarChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BarChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"barChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"indexBy"}},{"kind":"Field","name":{"kind":"Name","value":"keys"}},{"kind":"Field","name":{"kind":"Name","value":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<BarChartDataQuery, BarChartDataQueryVariables>;
export const LineChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LineChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lineChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<LineChartDataQuery, LineChartDataQueryVariables>;
export const PieChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PieChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pieChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<PieChartDataQuery, PieChartDataQueryVariables>;
export const DeleteConnectedAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteConnectedAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteConnectedAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode<DeleteConnectedAccountMutation, DeleteConnectedAccountMutationVariables>;
export const SaveImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SaveImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handle"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EmailAccountConnectionParameters"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"saveImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"accountOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"handle"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handle"}}},{"kind":"Argument","name":{"kind":"Name","value":"connectionParameters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}}},{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}}]}}]}}]} as unknown as DocumentNode<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>;
export const StartChannelSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartChannelSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startChannelSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<StartChannelSyncMutation, StartChannelSyncMutationVariables>;
export const UpdateCalendarChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCalendarChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCalendarChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCalendarChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateCalendarChannelMutation, UpdateCalendarChannelMutationVariables>;
export const UpdateMessageChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageChannelMutation, UpdateMessageChannelMutationVariables>;
export const UpdateMessageFolderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFolderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFolderMutation, UpdateMessageFolderMutationVariables>;
export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFoldersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFoldersMutation, UpdateMessageFoldersMutationVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"accountOwnerId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
export const SetAdminAiModelEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelEnabled"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelEnabledMutation, SetAdminAiModelEnabledMutationVariables>;
export const GetAdminAiModelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoEnableNewModels"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"modelFamily"}},{"kind":"Field","name":{"kind":"Name","value":"inferenceProvider"}},{"kind":"Field","name":{"kind":"Name","value":"isAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isAdminEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"deprecated"}},{"kind":"Field","name":{"kind":"Name","value":"isRecommended"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminAiModelsQuery, GetAdminAiModelsQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"websiteUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
@@ -1,6 +1,9 @@
import { type CalendarChannel } from '@/accounts/types/CalendarChannel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { UPDATE_CALENDAR_CHANNEL } from '@/settings/accounts/graphql/mutations/updateCalendarChannel';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation } from '@apollo/client/react';
import { SettingsAccountsEventVisibilitySettingsCard } from '@/settings/accounts/components/SettingsAccountsCalendarVisibilitySettingsCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { styled } from '@linaria/react';
@@ -27,26 +30,33 @@ type SettingsAccountsCalendarChannelDetailsProps = {
export const SettingsAccountsCalendarChannelDetails = ({
calendarChannel,
}: SettingsAccountsCalendarChannelDetailsProps) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { updateOneRecord } = useUpdateOneRecord();
const [updateMetadataChannel] = useMutation(UPDATE_CALENDAR_CHANNEL);
const updateChannel = (update: Record<string, unknown>) => {
if (isMigrated) {
updateMetadataChannel({
variables: { input: { id: calendarChannel.id, update } },
});
} else {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: update,
});
}
};
const handleVisibilityChange = (value: CalendarChannelVisibility) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: {
visibility: value,
},
});
updateChannel({ visibility: value });
};
const handleContactAutoCreationToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: {
isContactAutoCreationEnabled: value,
},
});
updateChannel({ isContactAutoCreationEnabled: value });
};
return (
@@ -1,17 +1,9 @@
import { styled } from '@linaria/react';
import {
type CalendarChannel,
CalendarChannelSyncStage,
} from '@/accounts/types/CalendarChannel';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsCalendarChannelDetails } from '@/settings/accounts/components/SettingsAccountsCalendarChannelDetails';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountCalendarChannelsTabListComponentId';
import { useMyCalendarChannels } from '@/settings/accounts/hooks/useMyCalendarChannels';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -27,33 +19,8 @@ export const SettingsAccountsCalendarChannelsContainer = () => {
activeTabIdComponentState,
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
});
const { records: calendarChannels } = useFindManyRecords<
CalendarChannel & {
connectedAccount: ConnectedAccount;
}
>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
in: accounts.map((account) => account.id),
},
syncStage: {
neq: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
skip: !accounts.length,
});
const { channels: calendarChannels } = useMyCalendarChannels();
const tabs = [
...calendarChannels.map((calendarChannel) => ({
@@ -5,8 +5,11 @@ import {
type MessageChannelContactAutoCreationPolicy,
type MessageFolderImportPolicy,
} from '@/accounts/types/MessageChannel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation } from '@apollo/client/react';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
@@ -40,58 +43,49 @@ const StyledDetailsContainer = styled.div`
export const SettingsAccountsMessageChannelDetails = ({
messageChannel,
}: SettingsAccountsMessageChannelDetailsProps) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { updateOneRecord } = useUpdateOneRecord();
const [updateMetadataChannel] = useMutation(UPDATE_MESSAGE_CHANNEL);
const updateChannel = (update: Record<string, unknown>) => {
if (isMigrated) {
updateMetadataChannel({
variables: { input: { id: messageChannel.id, update } },
});
} else {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: update,
});
}
};
const handleVisibilityChange = (value: MessageChannelVisibility) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
visibility: value,
},
});
updateChannel({ visibility: value });
};
const handleContactAutoCreationChange = (
value: MessageChannelContactAutoCreationPolicy,
) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
contactAutoCreationPolicy: value,
},
});
updateChannel({ contactAutoCreationPolicy: value });
};
const handleIsGroupEmailExcludedToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
excludeGroupEmails: value,
},
});
updateChannel({ excludeGroupEmails: value });
};
const handleIsNonProfessionalEmailExcludedToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
excludeNonProfessionalEmails: value,
},
});
updateChannel({ excludeNonProfessionalEmails: value });
};
const handleMessageFolderImportPolicyChange = (
value: MessageFolderImportPolicy,
) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: { messageFolderImportPolicy: value },
});
updateChannel({ messageFolderImportPolicy: value });
};
return (
@@ -1,23 +1,15 @@
import { styled } from '@linaria/react';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type MessageChannel,
MessageChannelSyncStage,
} from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { SettingsAccountsSelectedMessageChannelEffect } from '@/settings/accounts/components/SettingsAccountsSelectedMessageChannelEffect';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import React, { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -31,48 +23,11 @@ export const SettingsAccountsMessageChannelsContainer = () => {
activeTabIdComponentState,
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
});
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
});
const { records: messageChannels } = useFindManyRecords<
MessageChannel & {
connectedAccount: ConnectedAccount;
}
>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
in: accounts.map((account) => account.id),
},
isSyncEnabled: {
eq: true,
},
syncStage: {
neq: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
},
recordGqlFields,
onCompleted: (data) => {
setSettingsAccountsSelectedMessageChannel(data[0]);
},
skip: !accounts.length,
});
const { channels: messageChannels } = useMyMessageChannels();
const tabs = messageChannels.map((messageChannel) => ({
id: messageChannel.id,
@@ -97,6 +52,9 @@ export const SettingsAccountsMessageChannelsContainer = () => {
return (
<>
<SettingsAccountsSelectedMessageChannelEffect
messageChannels={messageChannels}
/>
{tabs.length > 1 && (
<StyledMessageContainer>
<TabList
@@ -1,12 +1,16 @@
import { useApolloClient, useMutation } from '@apollo/client/react';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { CalendarChannelSyncStage } from '@/accounts/types/CalendarChannel';
import { MessageChannelSyncStage } from '@/accounts/types/MessageChannel';
import {
CoreObjectNameSingular,
ConnectedAccountProvider,
FeatureFlagKey,
SettingsPath,
} from 'twenty-shared/types';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -27,6 +31,8 @@ import {
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { DELETE_CONNECTED_ACCOUNT } from '../graphql/mutations/deleteConnectedAccount';
type SettingsAccountsRowDropdownMenuProps = {
account: ConnectedAccount;
@@ -45,9 +51,17 @@ export const SettingsAccountsRowDropdownMenu = ({
const navigate = useNavigateSettings();
const { closeDropdown } = useCloseDropdown();
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
});
const [deleteConnectedAccountMutation] = useMutation(
DELETE_CONNECTED_ACCOUNT,
);
const { triggerProviderReconnect } = useTriggerProviderReconnect();
const hasPendingConfiguration =
@@ -61,7 +75,14 @@ export const SettingsAccountsRowDropdownMenu = ({
);
const deleteAccount = async () => {
await destroyOneRecord(account.id);
if (isMigrated) {
await deleteConnectedAccountMutation({
variables: { id: account.id },
});
await apolloClient.refetchQueries({ include: 'active' });
} else {
await destroyOneRecord(account.id);
}
};
return (
@@ -0,0 +1,40 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
type SettingsAccountsSelectedMessageChannelEffectProps = {
messageChannels: MessageChannel[];
};
export const SettingsAccountsSelectedMessageChannelEffect = ({
messageChannels,
}: SettingsAccountsSelectedMessageChannelEffectProps) => {
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
useEffect(() => {
if (messageChannels.length === 0) {
return;
}
const currentSelectionStillExists = activeTabId
? messageChannels.some((channel) => channel.id === activeTabId)
: false;
if (!currentSelectionStillExists) {
setSettingsAccountsSelectedMessageChannel(messageChannels[0]);
}
}, [messageChannels, activeTabId, setSettingsAccountsSelectedMessageChannel]);
return null;
};
@@ -1,13 +1,10 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader';
import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem';
import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle';
import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
import { useMyMessageFolders } from '@/settings/accounts/hooks/useMyMessageFolders';
import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -75,19 +72,9 @@ export const SettingsAccountsMessageFoldersCard = () => {
const { updateMessageFoldersSyncStatus } =
useUpdateMessageFoldersSyncStatus();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { record: messageChannel, loading } = useFindOneRecord<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
objectRecordId: settingsAccountsSelectedMessageChannel?.id,
recordGqlFields,
});
const { messageFolders = [] } = messageChannel ?? {};
const { messageFolders, loading } = useMyMessageFolders(
settingsAccountsSelectedMessageChannel?.id,
);
const filteredMessageFolders = useMemo(() => {
return messageFolders.filter((folder) =>
@@ -146,4 +146,25 @@ describe('computeMessageFolderTree', () => {
expect(result[0].children[0].folder.name).toBe('Clients');
expect(result[0].children[1].folder.name).toBe('Projects');
});
it('should resolve parent-child when parentFolderId references parent id instead of externalId', () => {
const parent = createFolder(
'20202020-aaaa-bbbb-cccc-000000000001',
'custom folder',
null,
'Label_5900090362003645629',
);
const child = createFolder(
'20202020-aaaa-bbbb-cccc-000000000002',
'child folder',
'20202020-aaaa-bbbb-cccc-000000000001',
'Label_7713410187110265162',
);
const result = computeMessageFolderTree([parent, child]);
expect(result).toHaveLength(1);
expect(result[0].folder.name).toBe('custom folder');
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].folder.name).toBe('child folder');
});
});
@@ -16,9 +16,11 @@ export const computeFolderIdsForSyncToggle = ({
const collectChildren = (id: string): string[] => {
const folder = folderById.get(id);
const children = folder?.externalId
const children = folder
? allFolders.filter(
(childFolder) => childFolder.parentFolderId === folder.externalId,
(childFolder) =>
childFolder.parentFolderId === folder.externalId ||
childFolder.parentFolderId === folder.id,
)
: [];
@@ -38,7 +40,9 @@ export const computeFolderIdsForSyncToggle = ({
break;
}
const parent = folderByExternalId.get(current.parentFolderId);
const parent =
folderByExternalId.get(current.parentFolderId) ??
folderById.get(current.parentFolderId);
if (!parent) {
break;
@@ -63,7 +67,9 @@ export const computeFolderIdsForSyncToggle = ({
for (const parent of collectParents(folderId)) {
const children = allFolders.filter(
(folder) => folder.parentFolderId === parent.externalId,
(folder) =>
folder.parentFolderId === parent.externalId ||
folder.parentFolderId === parent.id,
);
const hasOtherSyncedChild = children.some(
(child) => child.isSynced && !idsToUnsync.has(child.id),
@@ -11,17 +11,22 @@ export const computeMessageFolderTree = (
folders: MessageFolder[],
): MessageFolderTreeNode[] => {
const folderByExternalIdMap = new Map<string, MessageFolder>();
const folderByIdMap = new Map<string, MessageFolder>();
const childrenMap = new Map<string, MessageFolder[]>();
folders.forEach((folder) => {
if (isDefined(folder.externalId)) {
folderByExternalIdMap.set(folder.externalId, folder);
}
folderByIdMap.set(folder.id, folder);
});
folders.forEach((folder) => {
if (isDefined(folder.parentFolderId)) {
const parent = folderByExternalIdMap.get(folder.parentFolderId);
const parent =
folderByExternalIdMap.get(folder.parentFolderId) ??
folderByIdMap.get(folder.parentFolderId);
if (isDefined(parent)) {
const siblings = childrenMap.get(parent.id) || [];
siblings.push(folder);
@@ -46,7 +51,10 @@ export const computeMessageFolderTree = (
const rootFolders = folders.filter((folder) => {
if (!folder.parentFolderId) return true;
return !folderByExternalIdMap.has(folder.parentFolderId);
return (
!folderByExternalIdMap.has(folder.parentFolderId) &&
!folderByIdMap.has(folder.parentFolderId)
);
});
rootFolders.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_CONNECTED_ACCOUNT = gql`
mutation DeleteConnectedAccount($id: UUID!) {
deleteConnectedAccount(id: $id) {
id
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_CALENDAR_CHANNEL = gql`
mutation UpdateCalendarChannel($input: UpdateCalendarChannelInput!) {
updateCalendarChannel(input: $input) {
id
visibility
isContactAutoCreationEnabled
contactAutoCreationPolicy
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_CHANNEL = gql`
mutation UpdateMessageChannel($input: UpdateMessageChannelInput!) {
updateMessageChannel(input: $input) {
id
visibility
contactAutoCreationPolicy
excludeNonProfessionalEmails
excludeGroupEmails
messageFolderImportPolicy
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_FOLDER = gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
isSynced
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_FOLDERS = gql`
mutation UpdateMessageFolders($input: UpdateMessageFoldersInput!) {
updateMessageFolders(input: $input) {
id
isSynced
}
}
`;
@@ -0,0 +1,20 @@
import { gql } from '@apollo/client';
export const GET_MY_CALENDAR_CHANNELS = gql`
query MyCalendarChannels($connectedAccountId: UUID) {
myCalendarChannels(connectedAccountId: $connectedAccountId) {
id
handle
visibility
syncStatus
syncStage
syncStageStartedAt
isContactAutoCreationEnabled
contactAutoCreationPolicy
isSyncEnabled
connectedAccountId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const GET_MY_CONNECTED_ACCOUNTS = gql`
query MyConnectedAccounts {
myConnectedAccounts {
id
handle
provider
authFailedAt
scopes
handleAliases
lastSignedInAt
userWorkspaceId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,24 @@
import { gql } from '@apollo/client';
export const GET_MY_MESSAGE_CHANNELS = gql`
query MyMessageChannels($connectedAccountId: UUID) {
myMessageChannels(connectedAccountId: $connectedAccountId) {
id
handle
visibility
type
isContactAutoCreationEnabled
contactAutoCreationPolicy
messageFolderImportPolicy
excludeNonProfessionalEmails
excludeGroupEmails
isSyncEnabled
syncStatus
syncStage
syncStageStartedAt
connectedAccountId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const GET_MY_MESSAGE_FOLDERS = gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
name
isSynced
isSentFolder
parentFolderId
externalId
messageChannelId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,111 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type CalendarChannel,
CalendarChannelSyncStage,
} from '@/accounts/types/CalendarChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { GET_MY_CALENDAR_CHANNELS } from '@/settings/accounts/graphql/queries/getMyCalendarChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataCalendarChannel = {
id: string;
handle: string;
visibility: string;
syncStatus: string;
syncStage: string;
syncStageStartedAt: string | null;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: string;
isSyncEnabled: boolean;
connectedAccountId: string;
createdAt: string;
updatedAt: string;
};
export const useMyCalendarChannels = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { records: workspaceAccounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
skip: isMigrated,
});
const { records: workspaceChannels, loading: workspaceLoading } =
useFindManyRecords<
CalendarChannel & { connectedAccount: ConnectedAccount }
>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
in: workspaceAccounts.map((account) => account.id),
},
syncStage: {
neq: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
skip: isMigrated || !workspaceAccounts.length,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myCalendarChannels: MetadataCalendarChannel[];
}>(GET_MY_CALENDAR_CHANNELS, {
client: apolloClient,
skip: !isMigrated,
});
const channels = useMemo(() => {
if (!isMigrated) {
return workspaceChannels;
}
if (!metadataData?.myCalendarChannels) {
return [];
}
return metadataData.myCalendarChannels
.filter(
(channel: MetadataCalendarChannel) =>
channel.syncStage !== 'PENDING_CONFIGURATION',
)
.map(
(channel: MetadataCalendarChannel) =>
({
id: channel.id,
handle: channel.handle,
visibility: channel.visibility,
isContactAutoCreationEnabled: channel.isContactAutoCreationEnabled,
contactAutoCreationPolicy: channel.contactAutoCreationPolicy,
isSyncEnabled: channel.isSyncEnabled,
syncStatus: channel.syncStatus,
syncStage: channel.syncStage,
syncCursor: '',
syncStageStartedAt: channel.syncStageStartedAt
? new Date(channel.syncStageStartedAt)
: null,
throttleFailureCount: 0,
connectedAccountId: channel.connectedAccountId,
__typename: 'CalendarChannel',
}) as CalendarChannel,
);
}, [isMigrated, workspaceChannels, metadataData]);
return {
channels,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -0,0 +1,116 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { useMyCalendarChannels } from '@/settings/accounts/hooks/useMyCalendarChannels';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataConnectedAccount = {
id: string;
handle: string;
provider: string;
authFailedAt: string | null;
scopes: string[] | null;
handleAliases: string[] | null;
lastSignedInAt: string | null;
userWorkspaceId: string;
createdAt: string;
updatedAt: string;
};
export const useMyConnectedAccounts = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: workspaceAccounts, loading: workspaceLoading } =
useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
recordGqlFields,
skip: isMigrated,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myConnectedAccounts: MetadataConnectedAccount[];
}>(GET_MY_CONNECTED_ACCOUNTS, {
client: apolloClient,
skip: !isMigrated,
});
const { channels: messageChannels, loading: messageChannelsLoading } =
useMyMessageChannels();
const { channels: calendarChannels, loading: calendarChannelsLoading } =
useMyCalendarChannels();
const accounts = useMemo<ConnectedAccount[]>(() => {
if (!isMigrated) {
return workspaceAccounts;
}
if (!metadataData?.myConnectedAccounts) {
return [];
}
return metadataData.myConnectedAccounts.map(
(account: MetadataConnectedAccount) =>
({
id: account.id,
handle: account.handle,
provider: account.provider,
accessToken: '',
refreshToken: '',
accountOwnerId: account.userWorkspaceId,
lastSyncHistoryId: '',
authFailedAt: account.authFailedAt
? new Date(account.authFailedAt)
: null,
messageChannels: messageChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
scopes: account.scopes,
__typename: 'ConnectedAccount',
}) as ConnectedAccount,
);
}, [
isMigrated,
workspaceAccounts,
metadataData,
messageChannels,
calendarChannels,
]);
return {
accounts,
loading: isMigrated
? metadataLoading || messageChannelsLoading || calendarChannelsLoading
: workspaceLoading,
};
};
@@ -0,0 +1,127 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type MessageChannel,
MessageChannelSyncStage,
} from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataMessageChannel = {
id: string;
handle: string;
visibility: string;
type: string;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: string;
messageFolderImportPolicy: string;
excludeNonProfessionalEmails: boolean;
excludeGroupEmails: boolean;
isSyncEnabled: boolean;
syncStatus: string;
syncStage: string;
syncStageStartedAt: string | null;
connectedAccountId: string;
createdAt: string;
updatedAt: string;
};
export const useMyMessageChannels = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { records: workspaceAccounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
skip: isMigrated,
});
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
});
const { records: workspaceChannels, loading: workspaceLoading } =
useFindManyRecords<MessageChannel & { connectedAccount: ConnectedAccount }>(
{
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
in: workspaceAccounts.map((account) => account.id),
},
isSyncEnabled: { eq: true },
syncStage: {
neq: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
},
recordGqlFields,
skip: isMigrated || !workspaceAccounts.length,
},
);
const { data: metadataData, loading: metadataLoading } = useQuery<{
myMessageChannels: MetadataMessageChannel[];
}>(GET_MY_MESSAGE_CHANNELS, {
client: apolloClient,
skip: !isMigrated,
});
const channels = useMemo(() => {
if (!isMigrated) {
return workspaceChannels;
}
if (!metadataData?.myMessageChannels) {
return [];
}
return metadataData.myMessageChannels
.filter(
(channel: MetadataMessageChannel) =>
channel.isSyncEnabled &&
channel.syncStage !== 'PENDING_CONFIGURATION',
)
.map(
(channel: MetadataMessageChannel) =>
({
id: channel.id,
handle: channel.handle,
visibility: channel.visibility,
contactAutoCreationPolicy: channel.contactAutoCreationPolicy,
excludeNonProfessionalEmails: channel.excludeNonProfessionalEmails,
excludeGroupEmails: channel.excludeGroupEmails,
isSyncEnabled: channel.isSyncEnabled,
messageFolders: [],
messageFolderImportPolicy: channel.messageFolderImportPolicy,
syncStatus: channel.syncStatus,
syncStage: channel.syncStage,
syncCursor: '',
syncStageStartedAt: channel.syncStageStartedAt
? new Date(channel.syncStageStartedAt)
: null,
throttleFailureCount: 0,
connectedAccountId: channel.connectedAccountId,
__typename: 'MessageChannel',
}) as MessageChannel,
);
}, [isMigrated, workspaceChannels, metadataData]);
return {
channels,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -0,0 +1,80 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_MESSAGE_FOLDERS } from '@/settings/accounts/graphql/queries/getMyMessageFolders';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataMessageFolder = {
id: string;
name: string | null;
isSynced: boolean;
isSentFolder: boolean;
parentFolderId: string | null;
externalId: string | null;
messageChannelId: string;
createdAt: string;
updatedAt: string;
};
export const useMyMessageFolders = (messageChannelId?: string) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { record: messageChannel, loading: workspaceLoading } =
useFindOneRecord<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
objectRecordId: messageChannelId,
recordGqlFields,
skip: isMigrated || !messageChannelId,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myMessageFolders: MetadataMessageFolder[];
}>(GET_MY_MESSAGE_FOLDERS, {
client: apolloClient,
variables: messageChannelId ? { messageChannelId } : undefined,
skip: !isMigrated,
});
const messageFolders = useMemo<MessageFolder[]>(() => {
if (!isMigrated) {
return messageChannel?.messageFolders ?? [];
}
if (!metadataData?.myMessageFolders) {
return [];
}
return metadataData.myMessageFolders.map(
(folder: MetadataMessageFolder) => ({
id: folder.id,
name: folder.name ?? '',
syncCursor: '',
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
parentFolderId: folder.parentFolderId,
messageChannelId: folder.messageChannelId,
externalId: folder.externalId,
__typename: 'MessageFolder' as const,
}),
);
}, [isMigrated, messageChannel, metadataData]);
return {
messageFolders,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -1,7 +1,10 @@
import { useCallback } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { UPDATE_MESSAGE_FOLDERS } from '@/settings/accounts/graphql/mutations/updateMessageFolders';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient } from '@apollo/client/react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type UpdateMessageFoldersSyncStatusArgs = {
messageFolderIds: string[];
@@ -9,6 +12,12 @@ type UpdateMessageFoldersSyncStatusArgs = {
};
export const useUpdateMessageFoldersSyncStatus = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { updateManyRecords } = useUpdateManyRecords({
objectNameSingular: CoreObjectNameSingular.MessageFolder,
recordGqlFields: {
@@ -22,12 +31,37 @@ export const useUpdateMessageFoldersSyncStatus = () => {
messageFolderIds,
isSynced,
}: UpdateMessageFoldersSyncStatusArgs) => {
if (isMigrated) {
if (messageFolderIds.length === 0) {
return;
}
await apolloClient.mutate({
mutation: UPDATE_MESSAGE_FOLDERS,
variables: {
input: {
ids: messageFolderIds,
update: { isSynced },
},
},
optimisticResponse: {
updateMessageFolders: messageFolderIds.map((id) => ({
__typename: 'MessageFolder',
id,
isSynced,
})),
},
});
return;
}
await updateManyRecords({
recordIdsToUpdate: messageFolderIds,
updateOneRecordInput: { isSynced },
});
},
[updateManyRecords],
[isMigrated, apolloClient, updateManyRecords],
);
return { updateMessageFoldersSyncStatus };
@@ -1,40 +1,20 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsAccountLoader } from '@/settings/accounts/components/SettingsAccountLoader';
import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/SettingsAccountsBlocklistSection';
import { SettingsAccountsConnectedAccountsListCard } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsListCard';
import { SettingsAccountsSettingsSection } from '@/settings/accounts/components/SettingsAccountsSettingsSection';
import { useMyConnectedAccounts } from '@/settings/accounts/hooks/useMyConnectedAccounts';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
export const SettingsAccounts = () => {
const { t } = useLingui();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: allAccounts, loading } =
useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
recordGqlFields,
});
const { accounts: allAccounts, loading } = useMyConnectedAccounts();
return (
<SubMenuTopBarContainer
@@ -5,14 +5,22 @@ import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomStat
import { type CalendarChannel } from '@/accounts/types/CalendarChannel';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import {
CoreObjectNameSingular,
FeatureFlagKey,
SettingsPath,
} from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { GET_MY_CALENDAR_CHANNELS } from '@/settings/accounts/graphql/queries/getMyCalendarChannels';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation, useQuery } from '@apollo/client/react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { StartChannelSyncDocument } from '~/generated-metadata/graphql';
import { SettingsAccountsConfigurationSelectedMessageChannelEffect } from '~/pages/settings/accounts/SettingsAccountsConfigurationSelectedMessageChannelEffect';
import { SettingsAccountsConfigurationStepCalendar } from '~/pages/settings/accounts/SettingsAccountsConfigurationStepCalendar';
import { SettingsAccountsConfigurationStepEmail } from '~/pages/settings/accounts/SettingsAccountsConfigurationStepEmail';
@@ -40,38 +48,66 @@ export const SettingsAccountsConfiguration = () => {
SettingsAccountsConfigurationStep.Email,
);
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: messageChannels } = useFindManyRecords<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
const { records: workspaceMessageChannels } =
useFindManyRecords<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
},
recordGqlFields,
onCompleted: (data) => {
if (isDefined(data[0])) {
setSettingsAccountsSelectedMessageChannel(data[0]);
}
},
skip: !connectedAccountId,
recordGqlFields,
onCompleted: (data) => {
if (isDefined(data[0])) {
setSettingsAccountsSelectedMessageChannel(data[0]);
}
},
skip: !connectedAccountId || isMigrated,
});
const { records: workspaceCalendarChannels } =
useFindManyRecords<CalendarChannel>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
skip: !connectedAccountId || isMigrated,
});
const { data: metadataMessageChannelData } = useQuery<{
myMessageChannels: MessageChannel[];
}>(GET_MY_MESSAGE_CHANNELS, {
variables: { connectedAccountId },
skip: !isMigrated || !connectedAccountId,
});
const { records: calendarChannels } = useFindManyRecords<CalendarChannel>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
skip: !connectedAccountId,
const { data: metadataCalendarChannelData } = useQuery<{
myCalendarChannels: CalendarChannel[];
}>(GET_MY_CALENDAR_CHANNELS, {
variables: { connectedAccountId },
skip: !isMigrated || !connectedAccountId,
});
const messageChannels = isMigrated
? (metadataMessageChannelData?.myMessageChannels ?? [])
: workspaceMessageChannels;
const calendarChannels = isMigrated
? (metadataCalendarChannelData?.myCalendarChannels ?? [])
: workspaceCalendarChannels;
const messageChannel = messageChannels[0];
const calendarChannel = calendarChannels[0];
@@ -106,13 +142,20 @@ export const SettingsAccountsConfiguration = () => {
if (showEmailStep) {
return (
<SettingsAccountsConfigurationStepEmail
messageChannel={messageChannel}
hasNextStep={isDefined(calendarChannel)}
isSubmitting={isSubmitting}
onNext={handleNext}
onAddAccount={handleAddAccount}
/>
<>
{isMigrated && (
<SettingsAccountsConfigurationSelectedMessageChannelEffect
messageChannel={messageChannel as unknown as MessageChannel}
/>
)}
<SettingsAccountsConfigurationStepEmail
messageChannel={messageChannel}
hasNextStep={isDefined(calendarChannel)}
isSubmitting={isSubmitting}
onNext={handleNext}
onAddAccount={handleAddAccount}
/>
</>
);
}
@@ -0,0 +1,25 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
type SettingsAccountsConfigurationSelectedMessageChannelEffectProps = {
messageChannel: MessageChannel | undefined;
};
export const SettingsAccountsConfigurationSelectedMessageChannelEffect = ({
messageChannel,
}: SettingsAccountsConfigurationSelectedMessageChannelEffectProps) => {
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
useEffect(() => {
if (isDefined(messageChannel)) {
setSettingsAccountsSelectedMessageChannel(messageChannel);
}
}, [messageChannel, setSettingsAccountsSelectedMessageChannel]);
return null;
};
@@ -2215,7 +2215,6 @@ type CalendarChannel {
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy!
isSyncEnabled: Boolean!
syncCursor: String
syncedAt: DateTime
syncStageStartedAt: DateTime
throttleFailureCount: Float!
@@ -2259,15 +2258,11 @@ type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
accessToken: String
refreshToken: String
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
connectionParameters: JSON
lastSignedInAt: DateTime
oidcTokenClaims: JSON
userWorkspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
@@ -2285,7 +2280,6 @@ type MessageChannel {
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
syncCursor: String
syncedAt: DateTime
syncStatus: MessageChannelSyncStatus!
syncStage: MessageChannelSyncStage!
@@ -2347,7 +2341,6 @@ enum MessageChannelSyncStage {
type MessageFolder {
id: UUID!
name: String
syncCursor: String
isSentFolder: Boolean!
isSynced: Boolean!
parentFolderId: UUID
@@ -3169,14 +3162,11 @@ type Query {
findWorkspaceFromInviteHash(inviteHash: String!): Workspace!
validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken!
getSSOIdentityProviders: [FindAvailableSSOIDP!]!
messageFolders(messageChannelId: UUID): [MessageFolder!]!
messageFolder(id: UUID!): MessageFolder
calendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
calendarChannel(id: UUID!): CalendarChannel
messageChannels(connectedAccountId: UUID): [MessageChannel!]!
messageChannel(id: UUID!): MessageChannel
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountDTO!]!
connectedAccounts: [ConnectedAccountDTO!]!
connectedAccount(id: UUID!): ConnectedAccountDTO
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
@@ -3474,18 +3464,11 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createMessageFolder(input: CreateMessageFolderInput!): MessageFolder!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
deleteMessageFolder(id: UUID!): MessageFolder!
createCalendarChannel(input: CreateCalendarChannelInput!): CalendarChannel!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
deleteCalendarChannel(id: UUID!): CalendarChannel!
createMessageChannel(input: CreateMessageChannelInput!): MessageChannel!
updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]!
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
deleteMessageChannel(id: UUID!): MessageChannel!
createConnectedAccount(input: CreateConnectedAccountInput!): ConnectedAccountDTO!
updateConnectedAccount(input: UpdateConnectedAccountInput!): ConnectedAccountDTO!
deleteConnectedAccount(id: UUID!): ConnectedAccountDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
@@ -4382,66 +4365,18 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
input CreateMessageFolderInput {
id: UUID
name: String
isSentFolder: Boolean!
isSynced: Boolean!
externalId: String
pendingSyncAction: MessageFolderPendingSyncAction!
messageChannelId: UUID!
parentFolderId: UUID
}
input UpdateMessageFolderInput {
id: UUID!
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageFolderInputUpdates {
name: String
syncCursor: String
isSynced: Boolean
pendingSyncAction: MessageFolderPendingSyncAction
}
input CreateCalendarChannelInput {
id: UUID
handle: String!
visibility: CalendarChannelVisibility!
syncStage: CalendarChannelSyncStage!
connectedAccountId: UUID!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy!
isSyncEnabled: Boolean!
}
input UpdateCalendarChannelInput {
id: UUID!
update: UpdateCalendarChannelInputUpdates!
}
input UpdateCalendarChannelInputUpdates {
visibility: CalendarChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Boolean
}
input CreateMessageChannelInput {
id: UUID
handle: String!
visibility: MessageChannelVisibility!
type: MessageChannelType!
syncStage: MessageChannelSyncStage!
connectedAccountId: UUID!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
input UpdateMessageFoldersInput {
ids: [UUID!]!
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageChannelInput {
@@ -4459,26 +4394,16 @@ input UpdateMessageChannelInputUpdates {
excludeGroupEmails: Boolean
}
input CreateConnectedAccountInput {
id: UUID
handle: String!
provider: String!
accessToken: String
refreshToken: String
scopes: [String!]
userWorkspaceId: UUID!
}
input UpdateConnectedAccountInput {
input UpdateCalendarChannelInput {
id: UUID!
update: UpdateConnectedAccountInputUpdates!
update: UpdateCalendarChannelInputUpdates!
}
input UpdateConnectedAccountInputUpdates {
accessToken: String
refreshToken: String
handleAliases: [String!]
scopes: [String!]
input UpdateCalendarChannelInputUpdates {
visibility: CalendarChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Boolean
}
input CreateWebhookInput {
@@ -1886,7 +1886,6 @@ export interface CalendarChannel {
isContactAutoCreationEnabled: Scalars['Boolean']
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Scalars['Boolean']
syncCursor?: Scalars['String']
syncedAt?: Scalars['DateTime']
syncStageStartedAt?: Scalars['DateTime']
throttleFailureCount: Scalars['Float']
@@ -1908,15 +1907,11 @@ export interface ConnectedAccountDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
accessToken?: Scalars['String']
refreshToken?: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
connectionParameters?: Scalars['JSON']
lastSignedInAt?: Scalars['DateTime']
oidcTokenClaims?: Scalars['JSON']
userWorkspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -1935,7 +1930,6 @@ export interface MessageChannel {
excludeGroupEmails: Scalars['Boolean']
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction
isSyncEnabled: Scalars['Boolean']
syncCursor?: Scalars['String']
syncedAt?: Scalars['DateTime']
syncStatus: MessageChannelSyncStatus
syncStage: MessageChannelSyncStage
@@ -1965,7 +1959,6 @@ export type MessageChannelSyncStage = 'PENDING_CONFIGURATION' | 'MESSAGE_LIST_FE
export interface MessageFolder {
id: Scalars['UUID']
name?: Scalars['String']
syncCursor?: Scalars['String']
isSentFolder: Scalars['Boolean']
isSynced: Scalars['Boolean']
parentFolderId?: Scalars['UUID']
@@ -2724,14 +2717,11 @@ export interface Query {
findWorkspaceFromInviteHash: Workspace
validatePasswordResetToken: ValidatePasswordResetToken
getSSOIdentityProviders: FindAvailableSSOIDP[]
messageFolders: MessageFolder[]
messageFolder?: MessageFolder
calendarChannels: CalendarChannel[]
calendarChannel?: CalendarChannel
messageChannels: MessageChannel[]
messageChannel?: MessageChannel
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountDTO[]
connectedAccounts: ConnectedAccountDTO[]
connectedAccount?: ConnectedAccountDTO
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
@@ -2928,18 +2918,11 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createMessageFolder: MessageFolder
updateMessageFolder: MessageFolder
deleteMessageFolder: MessageFolder
createCalendarChannel: CalendarChannel
updateCalendarChannel: CalendarChannel
deleteCalendarChannel: CalendarChannel
createMessageChannel: MessageChannel
updateMessageFolders: MessageFolder[]
updateMessageChannel: MessageChannel
deleteMessageChannel: MessageChannel
createConnectedAccount: ConnectedAccountDTO
updateConnectedAccount: ConnectedAccountDTO
deleteConnectedAccount: ConnectedAccountDTO
updateCalendarChannel: CalendarChannel
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
@@ -4994,7 +4977,6 @@ export interface CalendarChannelGenqlSelection{
isContactAutoCreationEnabled?: boolean | number
contactAutoCreationPolicy?: boolean | number
isSyncEnabled?: boolean | number
syncCursor?: boolean | number
syncedAt?: boolean | number
syncStageStartedAt?: boolean | number
throttleFailureCount?: boolean | number
@@ -5009,15 +4991,11 @@ export interface ConnectedAccountDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
accessToken?: boolean | number
refreshToken?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
connectionParameters?: boolean | number
lastSignedInAt?: boolean | number
oidcTokenClaims?: boolean | number
userWorkspaceId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -5037,7 +5015,6 @@ export interface MessageChannelGenqlSelection{
excludeGroupEmails?: boolean | number
pendingGroupEmailsAction?: boolean | number
isSyncEnabled?: boolean | number
syncCursor?: boolean | number
syncedAt?: boolean | number
syncStatus?: boolean | number
syncStage?: boolean | number
@@ -5054,7 +5031,6 @@ export interface MessageChannelGenqlSelection{
export interface MessageFolderGenqlSelection{
id?: boolean | number
name?: boolean | number
syncCursor?: boolean | number
isSentFolder?: boolean | number
isSynced?: boolean | number
parentFolderId?: boolean | number
@@ -5874,14 +5850,11 @@ export interface QueryGenqlSelection{
findWorkspaceFromInviteHash?: (WorkspaceGenqlSelection & { __args: {inviteHash: Scalars['String']} })
validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} })
getSSOIdentityProviders?: FindAvailableSSOIDPGenqlSelection
messageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
messageFolder?: (MessageFolderGenqlSelection & { __args: {id: Scalars['UUID']} })
calendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
calendarChannel?: (CalendarChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
messageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
messageChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountDTOGenqlSelection
connectedAccounts?: ConnectedAccountDTOGenqlSelection
connectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
minimalMetadata?: MinimalMetadataGenqlSelection
@@ -6103,18 +6076,11 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: CreateMessageFolderInput} })
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
deleteMessageFolder?: (MessageFolderGenqlSelection & { __args: {id: Scalars['UUID']} })
createCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: CreateCalendarChannelInput} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
deleteCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
createMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: CreateMessageChannelInput} })
updateMessageFolders?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFoldersInput} })
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
deleteMessageChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
createConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {input: CreateConnectedAccountInput} })
updateConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {input: UpdateConnectedAccountInput} })
deleteConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -6439,29 +6405,19 @@ export interface DeleteSsoInput {identityProviderId: Scalars['UUID']}
export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderStatus}
export interface CreateMessageFolderInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),isSentFolder: Scalars['Boolean'],isSynced: Scalars['Boolean'],externalId?: (Scalars['String'] | null),pendingSyncAction: MessageFolderPendingSyncAction,messageChannelId: Scalars['UUID'],parentFolderId?: (Scalars['UUID'] | null)}
export interface UpdateMessageFolderInput {id: Scalars['UUID'],update: UpdateMessageFolderInputUpdates}
export interface UpdateMessageFolderInputUpdates {name?: (Scalars['String'] | null),syncCursor?: (Scalars['String'] | null),isSynced?: (Scalars['Boolean'] | null),pendingSyncAction?: (MessageFolderPendingSyncAction | null)}
export interface UpdateMessageFolderInputUpdates {isSynced?: (Scalars['Boolean'] | null)}
export interface CreateCalendarChannelInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],visibility: CalendarChannelVisibility,syncStage: CalendarChannelSyncStage,connectedAccountId: Scalars['UUID'],isContactAutoCreationEnabled: Scalars['Boolean'],contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy,isSyncEnabled: Scalars['Boolean']}
export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateCalendarChannelInputUpdates}
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
export interface CreateMessageChannelInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],visibility: MessageChannelVisibility,type: MessageChannelType,syncStage: MessageChannelSyncStage,connectedAccountId: Scalars['UUID'],isContactAutoCreationEnabled: Scalars['Boolean'],contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy,messageFolderImportPolicy: MessageFolderImportPolicy,excludeNonProfessionalEmails: Scalars['Boolean'],excludeGroupEmails: Scalars['Boolean'],pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction,isSyncEnabled: Scalars['Boolean']}
export interface UpdateMessageFoldersInput {ids: Scalars['UUID'][],update: UpdateMessageFolderInputUpdates}
export interface UpdateMessageChannelInput {id: Scalars['UUID'],update: UpdateMessageChannelInputUpdates}
export interface UpdateMessageChannelInputUpdates {visibility?: (MessageChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (MessageChannelContactAutoCreationPolicy | null),messageFolderImportPolicy?: (MessageFolderImportPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null),excludeNonProfessionalEmails?: (Scalars['Boolean'] | null),excludeGroupEmails?: (Scalars['Boolean'] | null)}
export interface CreateConnectedAccountInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],provider: Scalars['String'],accessToken?: (Scalars['String'] | null),refreshToken?: (Scalars['String'] | null),scopes?: (Scalars['String'][] | null),userWorkspaceId: Scalars['UUID']}
export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateCalendarChannelInputUpdates}
export interface UpdateConnectedAccountInput {id: Scalars['UUID'],update: UpdateConnectedAccountInputUpdates}
export interface UpdateConnectedAccountInputUpdates {accessToken?: (Scalars['String'] | null),refreshToken?: (Scalars['String'] | null),handleAliases?: (Scalars['String'][] | null),scopes?: (Scalars['String'][] | null)}
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
@@ -87,8 +87,8 @@ export default {
352,
364,
365,
483,
484
478,
479
],
"types": {
"BillingProductDTO": {
@@ -4346,9 +4346,6 @@ export default {
"isSyncEnabled": [
6
],
"syncCursor": [
1
],
"syncedAt": [
4
],
@@ -4385,12 +4382,6 @@ export default {
"provider": [
1
],
"accessToken": [
1
],
"refreshToken": [
1
],
"lastCredentialsRefreshedAt": [
4
],
@@ -4403,15 +4394,9 @@ export default {
"scopes": [
1
],
"connectionParameters": [
15
],
"lastSignedInAt": [
4
],
"oidcTokenClaims": [
15
],
"userWorkspaceId": [
3
],
@@ -4459,9 +4444,6 @@ export default {
"isSyncEnabled": [
6
],
"syncCursor": [
1
],
"syncedAt": [
4
],
@@ -4507,9 +4489,6 @@ export default {
"name": [
1
],
"syncCursor": [
1
],
"isSentFolder": [
6
],
@@ -6484,7 +6463,7 @@ export default {
"getSSOIdentityProviders": [
184
],
"messageFolders": [
"myMessageFolders": [
249,
{
"messageChannelId": [
@@ -6492,33 +6471,7 @@ export default {
]
}
],
"messageFolder": [
249,
{
"id": [
3,
"UUID!"
]
}
],
"calendarChannels": [
235,
{
"connectedAccountId": [
3
]
}
],
"calendarChannel": [
235,
{
"id": [
3,
"UUID!"
]
}
],
"messageChannels": [
"myMessageChannels": [
241,
{
"connectedAccountId": [
@@ -6526,24 +6479,17 @@ export default {
]
}
],
"messageChannel": [
241,
{
"id": [
3,
"UUID!"
]
}
"myConnectedAccounts": [
240
],
"connectedAccounts": [
240
],
"connectedAccount": [
240,
"myCalendarChannels": [
235,
{
"id": [
3,
"UUID!"
"connectedAccountId": [
3
]
}
],
@@ -8446,66 +8392,21 @@ export default {
]
}
],
"createMessageFolder": [
249,
{
"input": [
459,
"CreateMessageFolderInput!"
]
}
],
"updateMessageFolder": [
249,
{
"input": [
460,
459,
"UpdateMessageFolderInput!"
]
}
],
"deleteMessageFolder": [
"updateMessageFolders": [
249,
{
"id": [
3,
"UUID!"
]
}
],
"createCalendarChannel": [
235,
{
"input": [
462,
"CreateCalendarChannelInput!"
]
}
],
"updateCalendarChannel": [
235,
{
"input": [
463,
"UpdateCalendarChannelInput!"
]
}
],
"deleteCalendarChannel": [
235,
{
"id": [
3,
"UUID!"
]
}
],
"createMessageChannel": [
241,
{
"input": [
465,
"CreateMessageChannelInput!"
461,
"UpdateMessageFoldersInput!"
]
}
],
@@ -8513,38 +8414,11 @@ export default {
241,
{
"input": [
466,
462,
"UpdateMessageChannelInput!"
]
}
],
"deleteMessageChannel": [
241,
{
"id": [
3,
"UUID!"
]
}
],
"createConnectedAccount": [
240,
{
"input": [
468,
"CreateConnectedAccountInput!"
]
}
],
"updateConnectedAccount": [
240,
{
"input": [
469,
"UpdateConnectedAccountInput!"
]
}
],
"deleteConnectedAccount": [
240,
{
@@ -8554,11 +8428,20 @@ export default {
]
}
],
"updateCalendarChannel": [
235,
{
"input": [
464,
"UpdateCalendarChannelInput!"
]
}
],
"createWebhook": [
256,
{
"input": [
471,
466,
"CreateWebhookInput!"
]
}
@@ -8567,7 +8450,7 @@ export default {
256,
{
"input": [
472,
467,
"UpdateWebhookInput!"
]
}
@@ -8588,7 +8471,7 @@ export default {
221,
{
"input": [
474,
469,
"CreateSkillInput!"
]
}
@@ -8597,7 +8480,7 @@ export default {
221,
{
"input": [
475,
470,
"UpdateSkillInput!"
]
}
@@ -8694,7 +8577,7 @@ export default {
"String!"
],
"connectionParameters": [
476,
471,
"EmailAccountConnectionParameters!"
],
"id": [
@@ -8706,7 +8589,7 @@ export default {
154,
{
"input": [
478,
473,
"UpdateLabPublicFeatureFlagInput!"
]
}
@@ -8879,7 +8762,7 @@ export default {
68,
{
"input": [
479,
474,
"CreateOneAppTokenInput!"
]
}
@@ -8912,7 +8795,7 @@ export default {
6,
{
"workspaceMigration": [
481,
476,
"WorkspaceMigrationInput!"
]
}
@@ -8986,7 +8869,7 @@ export default {
"String!"
],
"fileFolder": [
484,
479,
"FileFolder!"
],
"filePath": [
@@ -10983,159 +10866,31 @@ export default {
1
]
},
"CreateMessageFolderInput": {
"id": [
3
],
"name": [
1
],
"isSentFolder": [
6
],
"isSynced": [
6
],
"externalId": [
1
],
"pendingSyncAction": [
250
],
"messageChannelId": [
3
],
"parentFolderId": [
3
],
"__typename": [
1
]
},
"UpdateMessageFolderInput": {
"id": [
3
],
"update": [
461
460
],
"__typename": [
1
]
},
"UpdateMessageFolderInputUpdates": {
"name": [
1
],
"syncCursor": [
1
],
"isSynced": [
6
],
"pendingSyncAction": [
250
],
"__typename": [
1
]
},
"CreateCalendarChannelInput": {
"id": [
3
],
"handle": [
1
],
"visibility": [
238
],
"syncStage": [
237
],
"connectedAccountId": [
3
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
239
],
"isSyncEnabled": [
6
],
"__typename": [
1
]
},
"UpdateCalendarChannelInput": {
"id": [
"UpdateMessageFoldersInput": {
"ids": [
3
],
"update": [
464
],
"__typename": [
1
]
},
"UpdateCalendarChannelInputUpdates": {
"visibility": [
238
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
239
],
"isSyncEnabled": [
6
],
"__typename": [
1
]
},
"CreateMessageChannelInput": {
"id": [
3
],
"handle": [
1
],
"visibility": [
242
],
"type": [
243
],
"syncStage": [
248
],
"connectedAccountId": [
3
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
244
],
"messageFolderImportPolicy": [
245
],
"excludeNonProfessionalEmails": [
6
],
"excludeGroupEmails": [
6
],
"pendingGroupEmailsAction": [
246
],
"isSyncEnabled": [
6
460
],
"__typename": [
1
@@ -11146,7 +10901,7 @@ export default {
3
],
"update": [
467
463
],
"__typename": [
1
@@ -11178,55 +10933,29 @@ export default {
1
]
},
"CreateConnectedAccountInput": {
"id": [
3
],
"handle": [
1
],
"provider": [
1
],
"accessToken": [
1
],
"refreshToken": [
1
],
"scopes": [
1
],
"userWorkspaceId": [
3
],
"__typename": [
1
]
},
"UpdateConnectedAccountInput": {
"UpdateCalendarChannelInput": {
"id": [
3
],
"update": [
470
465
],
"__typename": [
1
]
},
"UpdateConnectedAccountInputUpdates": {
"accessToken": [
1
"UpdateCalendarChannelInputUpdates": {
"visibility": [
238
],
"refreshToken": [
1
"isContactAutoCreationEnabled": [
6
],
"handleAliases": [
1
"contactAutoCreationPolicy": [
239
],
"scopes": [
1
"isSyncEnabled": [
6
],
"__typename": [
1
@@ -11257,7 +10986,7 @@ export default {
3
],
"update": [
473
468
],
"__typename": [
1
@@ -11331,13 +11060,13 @@ export default {
},
"EmailAccountConnectionParameters": {
"IMAP": [
477
472
],
"SMTP": [
477
472
],
"CALDAV": [
477
472
],
"__typename": [
1
@@ -11376,7 +11105,7 @@ export default {
},
"CreateOneAppTokenInput": {
"appToken": [
480
475
],
"__typename": [
1
@@ -11392,7 +11121,7 @@ export default {
},
"WorkspaceMigrationInput": {
"actions": [
482
477
],
"__typename": [
1
@@ -11400,7 +11129,7 @@ export default {
},
"WorkspaceMigrationDeleteActionInput": {
"type": [
483
478
],
"metadataName": [
252
@@ -11428,7 +11157,7 @@ export default {
218,
{
"input": [
486,
481,
"LogicFunctionLogsInput!"
]
}
@@ -309,26 +309,44 @@ export class MigrateMessagingInfrastructureToMetadataCommand extends ActiveOrSus
}
if (messageFolders.length > 0) {
const externalIdToFolderIdMap = new Map(
messageFolders.map((folder) => [folder.externalId, folder.id]),
);
const coreMessageFolders = messageFolders
.filter((workspaceEntity) =>
migratedMessageChannelIds.has(workspaceEntity.messageChannelId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
name: workspaceEntity.name,
syncCursor: workspaceEntity.syncCursor,
isSentFolder: workspaceEntity.isSentFolder,
isSynced: workspaceEntity.isSynced,
parentFolderId: isNonEmptyString(workspaceEntity.parentFolderId)
? workspaceEntity.parentFolderId
: null,
externalId: workspaceEntity.externalId,
pendingSyncAction: workspaceEntity.pendingSyncAction,
messageChannelId: workspaceEntity.messageChannelId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
.map((workspaceEntity) => {
let resolvedParentFolderId: string | null = null;
if (isNonEmptyString(workspaceEntity.parentFolderId)) {
resolvedParentFolderId =
externalIdToFolderIdMap.get(workspaceEntity.parentFolderId) ??
null;
if (!resolvedParentFolderId) {
this.logger.warn(
`Message folder ${workspaceEntity.id}: could not resolve parentFolderId externalId "${workspaceEntity.parentFolderId}" to a UUID`,
);
}
}
return {
id: workspaceEntity.id,
name: workspaceEntity.name,
syncCursor: workspaceEntity.syncCursor,
isSentFolder: workspaceEntity.isSentFolder,
isSynced: workspaceEntity.isSynced,
parentFolderId: resolvedParentFolderId,
externalId: workspaceEntity.externalId,
pendingSyncAction: workspaceEntity.pendingSyncAction,
messageChannelId: workspaceEntity.messageChannelId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
};
});
await this.messageFolderRepository.save(
coreMessageFolders as unknown as MessageFolderEntity[],
@@ -1,9 +1,9 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMessagingInfrastructureMetadataEntities1773945207801
export class AddMessagingInfrastructureMetadataEntities1774005903909
implements MigrationInterface
{
name = 'AddMessagingInfrastructureMetadataEntities1773945207801';
name = 'AddMessagingInfrastructureMetadataEntities1774005903909';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
@@ -31,13 +31,13 @@ export class AddMessagingInfrastructureMetadataEntities1773945207801
`CREATE TYPE "core"."messageChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "visibility" "core"."messageChannel_visibility_enum" NOT NULL, "handle" character varying NOT NULL, "type" "core"."messageChannel_type_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."messageChannel_contactautocreationpolicy_enum" NOT NULL, "messageFolderImportPolicy" "core"."messageChannel_messagefolderimportpolicy_enum" NOT NULL, "excludeNonProfessionalEmails" boolean NOT NULL, "excludeGroupEmails" boolean NOT NULL, "pendingGroupEmailsAction" "core"."messageChannel_pendinggroupemailsaction_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStatus" "core"."messageChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."messageChannel_syncstage_enum" NOT NULL, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "throttleRetryAfter" TIMESTAMP WITH TIME ZONE, "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_438b9412475f39712ed065f77af" PRIMARY KEY ("id"))`,
`CREATE TABLE "core"."messageChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "visibility" "core"."messageChannel_visibility_enum" NOT NULL, "handle" character varying NOT NULL, "type" "core"."messageChannel_type_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL DEFAULT true, "contactAutoCreationPolicy" "core"."messageChannel_contactautocreationpolicy_enum" NOT NULL DEFAULT 'SENT', "messageFolderImportPolicy" "core"."messageChannel_messagefolderimportpolicy_enum" NOT NULL DEFAULT 'ALL_FOLDERS', "excludeNonProfessionalEmails" boolean NOT NULL DEFAULT true, "excludeGroupEmails" boolean NOT NULL DEFAULT true, "pendingGroupEmailsAction" "core"."messageChannel_pendinggroupemailsaction_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL DEFAULT true, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStatus" "core"."messageChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."messageChannel_syncstage_enum" NOT NULL, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "throttleRetryAfter" TIMESTAMP WITH TIME ZONE, "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_438b9412475f39712ed065f77af" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageFolder_pendingsyncaction_enum" AS ENUM('FOLDER_DELETION', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageFolder" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "syncCursor" character varying, "isSentFolder" boolean NOT NULL, "isSynced" boolean NOT NULL, "parentFolderId" uuid, "externalId" character varying, "pendingSyncAction" "core"."messageFolder_pendingsyncaction_enum" NOT NULL, "messageChannelId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_85cb5a339d9f7f1106dde9e4db8" PRIMARY KEY ("id"))`,
`CREATE TABLE "core"."messageFolder" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "syncCursor" character varying, "isSentFolder" boolean NOT NULL, "isSynced" boolean NOT NULL, "parentFolderId" uuid, "externalId" character varying, "pendingSyncAction" "core"."messageFolder_pendingsyncaction_enum" NOT NULL DEFAULT 'NONE', "messageChannelId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_85cb5a339d9f7f1106dde9e4db8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
@@ -52,7 +52,7 @@ export class AddMessagingInfrastructureMetadataEntities1773945207801
`CREATE TYPE "core"."calendarChannel_contactautocreationpolicy_enum" AS ENUM('AS_PARTICIPANT_AND_ORGANIZER', 'AS_PARTICIPANT', 'AS_ORGANIZER', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."calendarChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "syncStatus" "core"."calendarChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."calendarChannel_syncstage_enum" NOT NULL, "visibility" "core"."calendarChannel_visibility_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."calendarChannel_contactautocreationpolicy_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_796d701c0c35518517d0f3e0e0b" PRIMARY KEY ("id"))`,
`CREATE TABLE "core"."calendarChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "syncStatus" "core"."calendarChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."calendarChannel_syncstage_enum" NOT NULL, "visibility" "core"."calendarChannel_visibility_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL DEFAULT true, "contactAutoCreationPolicy" "core"."calendarChannel_contactautocreationpolicy_enum" NOT NULL DEFAULT 'AS_PARTICIPANT_AND_ORGANIZER', "isSyncEnabled" boolean NOT NULL DEFAULT true, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_796d701c0c35518517d0f3e0e0b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "FK_1c7af038a011e99c27044793c6a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
@@ -48,7 +48,8 @@ export class McpCoreController {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.mcpProtocolService.handleMCPCoreQuery(body, {
@@ -112,7 +112,7 @@ export class ApplicationDevelopmentResolver {
@Args() { applicationId }: GenerateApplicationTokenInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user?: { id: string },
@AuthUserWorkspaceId() userWorkspaceId?: string,
@AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId?: string,
): Promise<ApplicationTokenPairDTO> {
return this.applicationTokenService.generateApplicationTokenPair({
workspaceId,
@@ -37,7 +37,8 @@ export class SearchResolver {
@Query(() => SearchResultConnectionDTO)
async search(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Args()
{
@@ -362,7 +362,8 @@ export class WorkspaceResolver {
@ResolveField(() => [ViewDTO])
async views(
@Parent() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<ViewDTO[]> {
return this.viewService.findByWorkspaceId(workspace.id, userWorkspaceId);
}
@@ -1,11 +1,25 @@
import { type ExecutionContext, createParamDecorator } from '@nestjs/common';
import {
type ExecutionContext,
ForbiddenException,
createParamDecorator,
} from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
interface DecoratorOptions {
allowUndefined?: boolean;
}
export const AuthUserWorkspaceId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
(options: DecoratorOptions | undefined, ctx: ExecutionContext) => {
const request = getRequest(ctx);
if (!options?.allowUndefined && !request.userWorkspaceId) {
throw new ForbiddenException(
'This endpoint requires a user context. API keys are not supported.',
);
}
return request.userWorkspaceId;
},
);
@@ -7,6 +7,7 @@ import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/cale
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
import { CalendarChannelResolver } from 'src/engine/metadata-modules/calendar-channel/resolvers/calendar-channel.resolver';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
@@ -15,6 +16,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
AuthModule,
PermissionsModule,
FeatureFlagModule,
ConnectedAccountMetadataModule,
],
providers: [
CalendarChannelMetadataService,
@@ -1,43 +1,144 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import {
CalendarChannelSyncStage,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import {
CalendarChannelException,
CalendarChannelExceptionCode,
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
@Injectable()
export class CalendarChannelMetadataService {
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly repository: Repository<CalendarChannelEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
async findAll(workspaceId: string): Promise<CalendarChannelDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByConnectedAccountId(
connectedAccountId: string,
workspaceId: string,
): Promise<CalendarChannelDTO[]> {
async findByUserWorkspaceId({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<CalendarChannelDTO[]> {
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
return this.findByConnectedAccountIds({
connectedAccountIds: userAccountIds,
workspaceId,
});
}
async findByConnectedAccountIdForUser({
connectedAccountId,
userWorkspaceId,
workspaceId,
}: {
connectedAccountId: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<CalendarChannelDTO[]> {
await this.connectedAccountMetadataService.verifyOwnership({
id: connectedAccountId,
userWorkspaceId,
workspaceId,
});
return this.findByConnectedAccountId({ connectedAccountId, workspaceId });
}
async findByConnectedAccountId({
connectedAccountId,
workspaceId,
}: {
connectedAccountId: string;
workspaceId: string;
}): Promise<CalendarChannelDTO[]> {
return this.repository.find({
where: { connectedAccountId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<CalendarChannelDTO | null> {
async findByConnectedAccountIds({
connectedAccountIds,
workspaceId,
}: {
connectedAccountIds: string[];
workspaceId: string;
}): Promise<CalendarChannelDTO[]> {
if (connectedAccountIds.length === 0) {
return [];
}
return this.repository.find({
where: { connectedAccountId: In(connectedAccountIds), workspaceId },
});
}
async findById({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<CalendarChannelDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async verifyOwnership({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<CalendarChannelEntity> {
const calendarChannel = await this.repository.findOne({
where: { id, workspaceId },
});
if (!calendarChannel) {
throw new CalendarChannelException(
`Calendar channel ${id} not found`,
CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND,
);
}
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
if (!userAccountIds.includes(calendarChannel.connectedAccountId)) {
throw new CalendarChannelException(
`Calendar channel ${id} does not belong to user workspace ${userWorkspaceId}`,
CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION,
);
}
return calendarChannel;
}
async create(
data: Partial<CalendarChannelEntity> & {
workspaceId: string;
@@ -52,11 +153,15 @@ export class CalendarChannelMetadataService {
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<CalendarChannelEntity>,
): Promise<CalendarChannelDTO> {
async update({
id,
workspaceId,
data,
}: {
id: string;
workspaceId: string;
data: Partial<CalendarChannelEntity>;
}): Promise<CalendarChannelDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
@@ -65,13 +170,19 @@ export class CalendarChannelMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<CalendarChannelDTO> {
const entity = await this.repository.findOneOrFail({
async delete({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<CalendarChannelDTO> {
const calendarChannel = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
return calendarChannel;
}
}
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum CalendarChannelExceptionCode {
CALENDAR_CHANNEL_NOT_FOUND = 'CALENDAR_CHANNEL_NOT_FOUND',
INVALID_CALENDAR_CHANNEL_INPUT = 'INVALID_CALENDAR_CHANNEL_INPUT',
CALENDAR_CHANNEL_OWNERSHIP_VIOLATION = 'CALENDAR_CHANNEL_OWNERSHIP_VIOLATION',
}
const getCalendarChannelExceptionUserFriendlyMessage = (
@@ -17,6 +18,8 @@ const getCalendarChannelExceptionUserFriendlyMessage = (
return msg`Calendar channel not found.`;
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
return msg`Invalid calendar channel input.`;
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION:
return msg`You do not have access to this calendar channel.`;
default:
assertUnreachable(code);
}
@@ -96,10 +96,41 @@ export class CalendarChannelDataAccessService {
)
: await this.toCoreWhere(workspaceId, where);
return this.coreRepository.findOne({
const requestedRelations =
(options.relations as string[] | undefined)?.slice() ?? [];
const needsConnectedAccount =
requestedRelations.includes('connectedAccount');
const coreRelations = requestedRelations.filter(
(r) => r !== 'connectedAccount',
);
const result = await this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<CalendarChannelEntity>) as unknown as Promise<CalendarChannelWorkspaceEntity | null>;
relations: coreRelations,
} as FindOneOptions<CalendarChannelEntity>);
if (!result) {
return null;
}
const workspaceResult =
result as unknown as CalendarChannelWorkspaceEntity;
if (needsConnectedAccount) {
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: result.connectedAccountId },
});
if (connectedAccount) {
workspaceResult.connectedAccount = connectedAccount;
}
}
return workspaceResult;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
@@ -59,9 +59,7 @@ export class CalendarChannelDTO {
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
syncCursor: string | null;
@IsDateString()
@@ -49,17 +49,19 @@ export class CalendarChannelEntity extends WorkspaceRelatedEntity {
})
visibility: CalendarChannelVisibility;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
isContactAutoCreationEnabled: boolean;
@Column({
type: 'enum',
enum: CalendarChannelContactAutoCreationPolicy,
nullable: false,
default:
CalendarChannelContactAutoCreationPolicy.AS_PARTICIPANT_AND_ORGANIZER,
})
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
isSyncEnabled: boolean;
@Column({ type: 'varchar', nullable: true })
@@ -1,22 +1,21 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CreateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/create-calendar-channel.input';
import { UpdateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/update-calendar-channel.input';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
@@ -29,10 +28,11 @@ export class CalendarChannelResolver {
) {}
@Query(() => [CalendarChannelDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannels(
async myCalendarChannels(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args('connectedAccountId', {
type: () => UUIDScalarType,
nullable: true,
@@ -40,59 +40,39 @@ export class CalendarChannelResolver {
connectedAccountId?: string,
): Promise<CalendarChannelDTO[]> {
if (connectedAccountId) {
return this.calendarChannelMetadataService.findByConnectedAccountId(
connectedAccountId,
workspace.id,
return this.calendarChannelMetadataService.findByConnectedAccountIdForUser(
{
connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
},
);
}
return this.calendarChannelMetadataService.findAll(workspace.id);
}
@Query(() => CalendarChannelDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO | null> {
return this.calendarChannelMetadataService.findById(id, workspace.id);
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createCalendarChannel(
@Args('input') input: CreateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.create({
...input,
return this.calendarChannelMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateCalendarChannel(
@Args('input') input: UpdateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
await this.calendarChannelMetadataService.verifyOwnership({
id: input.id,
userWorkspaceId,
workspaceId: workspace.id,
});
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteCalendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.delete(id, workspace.id);
return this.calendarChannelMetadataService.update({
id: input.id,
workspaceId: workspace.id,
data: input.update,
});
}
}
@@ -1,6 +1,7 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -8,6 +9,10 @@ import {
CalendarChannelException,
CalendarChannelExceptionCode,
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof CalendarChannelException) {
@@ -16,11 +21,23 @@ export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
throw new NotFoundError(error);
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
throw new UserInputError(error);
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof ConnectedAccountException) {
switch (error.code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
throw new ForbiddenError(error);
default:
break;
}
}
throw error;
};
@@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -17,13 +21,85 @@ export class ConnectedAccountMetadataService {
return this.repository.find({ where: { workspaceId } });
}
async findById(
id: string,
workspaceId: string,
): Promise<ConnectedAccountDTO | null> {
async findByUserWorkspaceId({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO[]> {
return this.repository.find({
where: { userWorkspaceId, workspaceId },
});
}
async findById({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async findByIds({
ids,
workspaceId,
}: {
ids: string[];
workspaceId: string;
}): Promise<ConnectedAccountDTO[]> {
return this.repository.find({
where: { id: In(ids), workspaceId },
});
}
async verifyOwnership({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<ConnectedAccountEntity> {
const connectedAccount = await this.repository.findOne({
where: { id, workspaceId },
});
if (!connectedAccount) {
throw new ConnectedAccountException(
`Connected account ${id} not found`,
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
if (connectedAccount.userWorkspaceId !== userWorkspaceId) {
throw new ConnectedAccountException(
`Connected account ${id} does not belong to user workspace ${userWorkspaceId}`,
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION,
);
}
return connectedAccount;
}
async getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<string[]> {
const accounts = await this.repository.find({
where: { userWorkspaceId, workspaceId },
select: ['id'],
});
return accounts.map((account) => account.id);
}
async create(
data: Partial<ConnectedAccountEntity> & {
workspaceId: string;
@@ -37,11 +113,15 @@ export class ConnectedAccountMetadataService {
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<ConnectedAccountEntity>,
): Promise<ConnectedAccountDTO> {
async update({
id,
workspaceId,
data,
}: {
id: string;
workspaceId: string;
data: Partial<ConnectedAccountEntity>;
}): Promise<ConnectedAccountDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
@@ -50,13 +130,19 @@ export class ConnectedAccountMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<ConnectedAccountDTO> {
const entity = await this.repository.findOneOrFail({
async delete({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO> {
const connectedAccount = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
return connectedAccount;
}
}
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum ConnectedAccountExceptionCode {
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_CONNECTED_ACCOUNT_INPUT = 'INVALID_CONNECTED_ACCOUNT_INPUT',
CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION = 'CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION',
}
const getConnectedAccountExceptionUserFriendlyMessage = (
@@ -17,6 +18,8 @@ const getConnectedAccountExceptionUserFriendlyMessage = (
return msg`Connected account not found.`;
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
return msg`Invalid connected account input.`;
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
return msg`You do not have access to this connected account.`;
default:
assertUnreachable(code);
}
@@ -79,9 +79,15 @@ export class ConnectedAccountDataAccessService {
const coreData: Record<string, unknown> = { ...rest };
if (handleAliases !== undefined) {
coreData.handleAliases = isNonEmptyString(handleAliases)
? handleAliases.split(',').map((alias: string) => alias.trim())
: null;
if (Array.isArray(handleAliases)) {
coreData.handleAliases = handleAliases;
} else if (isNonEmptyString(handleAliases)) {
coreData.handleAliases = handleAliases
.split(',')
.map((alias: string) => alias.trim());
} else {
coreData.handleAliases = null;
}
}
if (accountOwnerId !== undefined) {
@@ -8,7 +8,6 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -29,14 +28,10 @@ export class ConnectedAccountDTO {
@Field()
provider: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
accessToken: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
refreshToken: string | null;
@IsDateString()
@@ -59,8 +54,7 @@ export class ConnectedAccountDTO {
@Field(() => [String], { nullable: true })
scopes: string[] | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
@HideField()
connectionParameters: Record<string, unknown> | null;
@IsDateString()
@@ -68,8 +62,7 @@ export class ConnectedAccountDTO {
@Field(() => Date, { nullable: true })
lastSignedInAt: Date | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
@HideField()
oidcTokenClaims: Record<string, unknown> | null;
@IsUUID()
@@ -1,49 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateConnectedAccountInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
accessToken?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
refreshToken?: string;
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
handleAliases?: string[];
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
scopes?: string[];
}
@InputType()
export class UpdateConnectedAccountInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateConnectedAccountInputUpdates)
@ValidateNested()
@Field(() => UpdateConnectedAccountInputUpdates)
update: UpdateConnectedAccountInputUpdates;
}
@@ -7,17 +7,17 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { CreateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/create-connected-account.input';
import { UpdateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/update-connected-account.input';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@@ -28,6 +28,19 @@ export class ConnectedAccountResolver {
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
@Query(() => [ConnectedAccountDTO])
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async myConnectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountDTO[]> {
return this.connectedAccountMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Query(() => [ConnectedAccountDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
@@ -37,50 +50,23 @@ export class ConnectedAccountResolver {
return this.connectedAccountMetadataService.findAll(workspace.id);
}
@Query(() => ConnectedAccountDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async connectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO | null> {
return this.connectedAccountMetadataService.findById(id, workspace.id);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createConnectedAccount(
@Args('input') input: CreateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateConnectedAccount(
@Args('input') input: UpdateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteConnectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.delete(id, workspace.id);
await this.connectedAccountMetadataService.verifyOwnership({
id,
userWorkspaceId,
workspaceId: workspace.id,
});
return this.connectedAccountMetadataService.delete({
id,
workspaceId: workspace.id,
});
}
}
@@ -1,6 +1,7 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -16,6 +17,8 @@ export const connectedAccountGraphqlApiExceptionHandler = (error: Error) => {
throw new NotFoundError(error);
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
throw new UserInputError(error);
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
@@ -96,10 +96,55 @@ export class MessageChannelDataAccessService {
)
: await this.toCoreWhere(workspaceId, where);
return this.coreRepository.findOne({
const requestedRelations =
(options.relations as string[] | undefined)?.slice() ?? [];
const needsConnectedAccount =
requestedRelations.includes('connectedAccount');
const needsMessageFolders = requestedRelations.includes('messageFolders');
const coreRelations = requestedRelations.filter(
(r) => r !== 'connectedAccount' && r !== 'messageFolders',
);
const result = await this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<MessageChannelEntity>) as unknown as Promise<MessageChannelWorkspaceEntity | null>;
relations: coreRelations,
} as FindOneOptions<MessageChannelEntity>);
if (!result) {
return null;
}
const workspaceResult =
result as unknown as MessageChannelWorkspaceEntity;
if (needsConnectedAccount) {
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: result.connectedAccountId },
});
if (connectedAccount) {
workspaceResult.connectedAccount = connectedAccount;
}
}
if (needsMessageFolders) {
const workspaceRepository =
await this.getWorkspaceRepository(workspaceId);
const workspaceChannel = await workspaceRepository.findOne({
where: { id: result.id },
relations: ['messageFolders'],
});
workspaceResult.messageFolders = workspaceChannel?.messageFolders ?? [];
}
return workspaceResult;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
@@ -75,9 +75,7 @@ export class MessageChannelDTO {
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
syncCursor: string | null;
@IsDateString()
@@ -46,13 +46,14 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
})
type: MessageChannelType;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
isContactAutoCreationEnabled: boolean;
@Column({
type: 'enum',
enum: MessageChannelContactAutoCreationPolicy,
nullable: false,
default: MessageChannelContactAutoCreationPolicy.SENT,
})
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
@@ -60,13 +61,14 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
type: 'enum',
enum: MessageFolderImportPolicy,
nullable: false,
default: MessageFolderImportPolicy.ALL_FOLDERS,
})
messageFolderImportPolicy: MessageFolderImportPolicy;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
excludeNonProfessionalEmails: boolean;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
excludeGroupEmails: boolean;
@Column({
@@ -76,7 +78,7 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
})
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
@Column({ type: 'boolean', nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
isSyncEnabled: boolean;
@Column({ type: 'varchar', nullable: true })
@@ -3,11 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import { MessageChannelResolver } from 'src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver';
import { MessageFolderDataAccessModule } from 'src/engine/metadata-modules/message-folder/data-access/message-folder-data-access.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
@Module({
imports: [
@@ -15,6 +18,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
AuthModule,
PermissionsModule,
FeatureFlagModule,
ConnectedAccountMetadataModule,
MessageFolderDataAccessModule,
MessagingImportManagerModule,
],
providers: [
MessageChannelMetadataService,
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import {
MessageChannelSyncStage,
@@ -9,36 +9,137 @@ import {
MessageChannelVisibility,
} from 'twenty-shared/types';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
@Injectable()
export class MessageChannelMetadataService {
constructor(
@InjectRepository(MessageChannelEntity)
private readonly repository: Repository<MessageChannelEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByConnectedAccountId(
connectedAccountId: string,
workspaceId: string,
): Promise<MessageChannelDTO[]> {
async findByUserWorkspaceId({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelDTO[]> {
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
return this.findByConnectedAccountIds({
connectedAccountIds: userAccountIds,
workspaceId,
});
}
async findByConnectedAccountIdForUser({
connectedAccountId,
userWorkspaceId,
workspaceId,
}: {
connectedAccountId: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelDTO[]> {
await this.connectedAccountMetadataService.verifyOwnership({
id: connectedAccountId,
userWorkspaceId,
workspaceId,
});
return this.findByConnectedAccountId({ connectedAccountId, workspaceId });
}
async findByConnectedAccountId({
connectedAccountId,
workspaceId,
}: {
connectedAccountId: string;
workspaceId: string;
}): Promise<MessageChannelDTO[]> {
return this.repository.find({
where: { connectedAccountId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<MessageChannelDTO | null> {
async findByConnectedAccountIds({
connectedAccountIds,
workspaceId,
}: {
connectedAccountIds: string[];
workspaceId: string;
}): Promise<MessageChannelDTO[]> {
if (connectedAccountIds.length === 0) {
return [];
}
return this.repository.find({
where: { connectedAccountId: In(connectedAccountIds), workspaceId },
});
}
async findById({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<MessageChannelDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async verifyOwnership({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelEntity> {
const messageChannel = await this.repository.findOne({
where: { id, workspaceId },
});
if (!messageChannel) {
throw new MessageChannelException(
`Message channel ${id} not found`,
MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND,
);
}
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
if (!userAccountIds.includes(messageChannel.connectedAccountId)) {
throw new MessageChannelException(
`Message channel ${id} does not belong to user workspace ${userWorkspaceId}`,
MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION,
);
}
return messageChannel;
}
async create(
data: Partial<MessageChannelEntity> & {
workspaceId: string;
@@ -54,11 +155,15 @@ export class MessageChannelMetadataService {
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<MessageChannelEntity>,
): Promise<MessageChannelDTO> {
async update({
id,
workspaceId,
data,
}: {
id: string;
workspaceId: string;
data: Partial<MessageChannelEntity>;
}): Promise<MessageChannelDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
@@ -67,13 +172,19 @@ export class MessageChannelMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<MessageChannelDTO> {
const entity = await this.repository.findOneOrFail({
async delete({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<MessageChannelDTO> {
const messageChannel = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
return messageChannel;
}
}
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum MessageChannelExceptionCode {
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT',
MESSAGE_CHANNEL_OWNERSHIP_VIOLATION = 'MESSAGE_CHANNEL_OWNERSHIP_VIOLATION',
}
const getMessageChannelExceptionUserFriendlyMessage = (
@@ -17,6 +18,8 @@ const getMessageChannelExceptionUserFriendlyMessage = (
return msg`Message channel not found.`;
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
return msg`Invalid message channel input.`;
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
return msg`You do not have access to this message channel.`;
default:
assertUnreachable(code);
}
@@ -1,24 +1,37 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-message-channel.input';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import {
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { MessageFolderDataAccessService } from 'src/engine/metadata-modules/message-folder/data-access/services/message-folder-data-access.service';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import { Not } from 'typeorm';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(MessageChannelGraphqlApiExceptionInterceptor)
@@ -26,13 +39,16 @@ import { MessageChannelMetadataService } from 'src/engine/metadata-modules/messa
export class MessageChannelResolver {
constructor(
private readonly messageChannelMetadataService: MessageChannelMetadataService,
private readonly messageFolderDataAccessService: MessageFolderDataAccessService,
private readonly messagingProcessGroupEmailActionsService: MessagingProcessGroupEmailActionsService,
) {}
@Query(() => [MessageChannelDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageChannels(
async myMessageChannels(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args('connectedAccountId', {
type: () => UUIDScalarType,
nullable: true,
@@ -40,59 +56,80 @@ export class MessageChannelResolver {
connectedAccountId?: string,
): Promise<MessageChannelDTO[]> {
if (connectedAccountId) {
return this.messageChannelMetadataService.findByConnectedAccountId(
connectedAccountId,
workspace.id,
return this.messageChannelMetadataService.findByConnectedAccountIdForUser(
{
connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
},
);
}
return this.messageChannelMetadataService.findAll(workspace.id);
}
@Query(() => MessageChannelDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO | null> {
return this.messageChannelMetadataService.findById(id, workspace.id);
}
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createMessageChannel(
@Args('input') input: CreateMessageChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.create({
...input,
return this.messageChannelMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateMessageChannel(
@Args('input') input: UpdateMessageChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
const messageChannel =
await this.messageChannelMetadataService.verifyOwnership({
id: input.id,
userWorkspaceId,
workspaceId: workspace.id,
});
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteMessageChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.delete(id, workspace.id);
const isSyncOngoing =
messageChannel.syncStage ===
MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING;
const foldersWithPendingAction =
await this.messageFolderDataAccessService.find(workspace.id, {
messageChannelId: messageChannel.id,
pendingSyncAction: Not(MessageFolderPendingSyncAction.NONE),
});
const hasPendingGroupEmailsAction =
messageChannel.pendingGroupEmailsAction !==
MessageChannelPendingGroupEmailsAction.NONE;
if (
isSyncOngoing &&
(foldersWithPendingAction.length > 0 || hasPendingGroupEmailsAction)
) {
throw new MessageChannelException(
'Cannot update message channel while sync is ongoing with pending actions',
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
if (
messageChannel.syncStage !==
MessageChannelSyncStage.PENDING_CONFIGURATION &&
isDefined(input.update.excludeGroupEmails) &&
input.update.excludeGroupEmails !== messageChannel.excludeGroupEmails
) {
// Service expects WorkspaceEntity type but only reads .id
await this.messagingProcessGroupEmailActionsService.markMessageChannelAsPendingGroupEmailsAction(
messageChannel as unknown as MessageChannelWorkspaceEntity,
workspace.id,
input.update.excludeGroupEmails
? MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_DELETION
: MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_IMPORT,
);
}
return this.messageChannelMetadataService.update({
id: input.id,
workspaceId: workspace.id,
data: input.update,
});
}
}
@@ -1,9 +1,14 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
import {
MessageChannelException,
MessageChannelExceptionCode,
@@ -16,11 +21,23 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
throw new NotFoundError(error);
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
throw new UserInputError(error);
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof ConnectedAccountException) {
switch (error.code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
throw new ForbiddenError(error);
default:
break;
}
}
throw error;
};
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import { validate as uuidValidate } from 'uuid';
import {
type FindOneOptions,
type FindOptionsWhere,
@@ -32,6 +33,31 @@ export class MessageFolderDataAccessService {
);
}
// Workspace stores parentFolderId as an externalId (text),
// core stores it as a uuid FK. Resolve during dual-write.
private async toCore(
workspaceId: string,
data: Partial<MessageFolderWorkspaceEntity>,
): Promise<Record<string, unknown>> {
const coreData: Record<string, unknown> = { ...data, workspaceId };
const parentFolderId = coreData.parentFolderId as string | null;
if (parentFolderId && !uuidValidate(parentFolderId)) {
const parentFolder = await this.coreRepository.findOne({
where: {
workspaceId,
messageChannelId: coreData.messageChannelId as string,
externalId: parentFolderId,
},
select: ['id'],
});
coreData.parentFolderId = parentFolder?.id ?? null;
}
return coreData;
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
@@ -94,10 +120,11 @@ export class MessageFolderDataAccessService {
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.save({
...data,
workspaceId,
} as unknown as MessageFolderEntity);
const coreData = await this.toCore(workspaceId, data);
await this.coreRepository.save(
coreData as unknown as MessageFolderEntity,
);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder to core: ${error}`,
@@ -25,9 +25,7 @@ export class MessageFolderDTO {
@Field(() => String, { nullable: true })
name: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
syncCursor: string | null;
@IsBoolean()
@@ -3,38 +3,20 @@ import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateMessageFolderInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
name?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
syncCursor?: string;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSynced?: boolean;
@IsOptional()
@IsEnum(MessageFolderPendingSyncAction)
@Field(() => MessageFolderPendingSyncAction, { nullable: true })
pendingSyncAction?: MessageFolderPendingSyncAction;
}
@InputType()
@@ -49,3 +31,16 @@ export class UpdateMessageFolderInput {
@Field(() => UpdateMessageFolderInputUpdates)
update: UpdateMessageFolderInputUpdates;
}
@InputType()
export class UpdateMessageFoldersInput {
@IsUUID('4', { each: true })
@IsNotEmpty({ each: true })
@Field(() => [UUIDScalarType])
ids: string[];
@Type(() => UpdateMessageFolderInputUpdates)
@ValidateNested()
@Field(() => UpdateMessageFolderInputUpdates)
update: UpdateMessageFolderInputUpdates;
}
@@ -41,6 +41,7 @@ export class MessageFolderEntity extends WorkspaceRelatedEntity {
type: 'enum',
enum: MessageFolderPendingSyncAction,
nullable: false,
default: MessageFolderPendingSyncAction.NONE,
})
pendingSyncAction: MessageFolderPendingSyncAction;
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
@@ -15,6 +17,8 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
AuthModule,
PermissionsModule,
FeatureFlagModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
],
providers: [
MessageFolderMetadataService,
@@ -1,40 +1,159 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import {
MessageFolderException,
MessageFolderExceptionCode,
} from 'src/engine/metadata-modules/message-folder/message-folder.exception';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
@Injectable()
export class MessageFolderMetadataService {
constructor(
@InjectRepository(MessageFolderEntity)
private readonly repository: Repository<MessageFolderEntity>,
private readonly messageChannelMetadataService: MessageChannelMetadataService,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
async findAll(workspaceId: string): Promise<MessageFolderDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByMessageChannelId(
messageChannelId: string,
workspaceId: string,
): Promise<MessageFolderDTO[]> {
async findByUserWorkspaceId({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageFolderDTO[]> {
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
const userChannels =
await this.messageChannelMetadataService.findByConnectedAccountIds({
connectedAccountIds: userAccountIds,
workspaceId,
});
const userChannelIds = userChannels.map((channel) => channel.id);
return this.findByMessageChannelIds({
messageChannelIds: userChannelIds,
workspaceId,
});
}
async findByMessageChannelIdForUser({
messageChannelId,
userWorkspaceId,
workspaceId,
}: {
messageChannelId: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageFolderDTO[]> {
await this.messageChannelMetadataService.verifyOwnership({
id: messageChannelId,
userWorkspaceId,
workspaceId,
});
return this.findByMessageChannelId({ messageChannelId, workspaceId });
}
async findByMessageChannelId({
messageChannelId,
workspaceId,
}: {
messageChannelId: string;
workspaceId: string;
}): Promise<MessageFolderDTO[]> {
return this.repository.find({
where: { messageChannelId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<MessageFolderDTO | null> {
async findByMessageChannelIds({
messageChannelIds,
workspaceId,
}: {
messageChannelIds: string[];
workspaceId: string;
}): Promise<MessageFolderDTO[]> {
if (messageChannelIds.length === 0) {
return [];
}
return this.repository.find({
where: { messageChannelId: In(messageChannelIds), workspaceId },
});
}
async findById({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<MessageFolderDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async verifyOwnership({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageFolderEntity> {
const messageFolder = await this.repository.findOne({
where: { id, workspaceId },
});
if (!messageFolder) {
throw new MessageFolderException(
`Message folder ${id} not found`,
MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND,
);
}
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
workspaceId,
});
const messageChannel = await this.messageChannelMetadataService.findById({
id: messageFolder.messageChannelId,
workspaceId,
});
if (
!messageChannel ||
!userAccountIds.includes(messageChannel.connectedAccountId)
) {
throw new MessageFolderException(
`Message folder ${id} does not belong to user workspace ${userWorkspaceId}`,
MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION,
);
}
return messageFolder;
}
async create(
data: Partial<MessageFolderEntity> & {
workspaceId: string;
@@ -47,11 +166,15 @@ export class MessageFolderMetadataService {
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<MessageFolderEntity>,
): Promise<MessageFolderDTO> {
async update({
id,
workspaceId,
data,
}: {
id: string;
workspaceId: string;
data: Partial<MessageFolderEntity>;
}): Promise<MessageFolderDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
@@ -60,13 +183,36 @@ export class MessageFolderMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<MessageFolderDTO> {
const entity = await this.repository.findOneOrFail({
async updateMany({
ids,
workspaceId,
data,
}: {
ids: string[];
workspaceId: string;
data: Partial<MessageFolderEntity>;
}): Promise<MessageFolderDTO[]> {
await this.repository.update(
{ id: In(ids), workspaceId },
data as Record<string, unknown>,
);
return this.repository.find({ where: { id: In(ids), workspaceId } });
}
async delete({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<MessageFolderDTO> {
const messageFolder = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
return messageFolder;
}
}
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum MessageFolderExceptionCode {
MESSAGE_FOLDER_NOT_FOUND = 'MESSAGE_FOLDER_NOT_FOUND',
INVALID_MESSAGE_FOLDER_INPUT = 'INVALID_MESSAGE_FOLDER_INPUT',
MESSAGE_FOLDER_OWNERSHIP_VIOLATION = 'MESSAGE_FOLDER_OWNERSHIP_VIOLATION',
}
const getMessageFolderExceptionUserFriendlyMessage = (
@@ -17,6 +18,8 @@ const getMessageFolderExceptionUserFriendlyMessage = (
return msg`Message folder not found.`;
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
return msg`Invalid message folder input.`;
case MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION:
return msg`You do not have access to this message folder.`;
default:
assertUnreachable(code);
}
@@ -1,22 +1,24 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/create-message-folder.input';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { UpdateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
import {
UpdateMessageFolderInput,
UpdateMessageFoldersInput,
} from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
@@ -29,10 +31,11 @@ export class MessageFolderResolver {
) {}
@Query(() => [MessageFolderDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolders(
async myMessageFolders(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args('messageChannelId', {
type: () => UUIDScalarType,
nullable: true,
@@ -40,59 +43,62 @@ export class MessageFolderResolver {
messageChannelId?: string,
): Promise<MessageFolderDTO[]> {
if (messageChannelId) {
return this.messageFolderMetadataService.findByMessageChannelId(
return this.messageFolderMetadataService.findByMessageChannelIdForUser({
messageChannelId,
workspace.id,
);
userWorkspaceId,
workspaceId: workspace.id,
});
}
return this.messageFolderMetadataService.findAll(workspace.id);
}
@Query(() => MessageFolderDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO | null> {
return this.messageFolderMetadataService.findById(id, workspace.id);
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createMessageFolder(
@Args('input') input: CreateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.create({
...input,
return this.messageFolderMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateMessageFolder(
@Args('input') input: UpdateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.update(
input.id,
workspace.id,
input.update,
);
await this.messageFolderMetadataService.verifyOwnership({
id: input.id,
userWorkspaceId,
workspaceId: workspace.id,
});
return this.messageFolderMetadataService.update({
id: input.id,
workspaceId: workspace.id,
data: input.update,
});
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@Mutation(() => [MessageFolderDTO])
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteMessageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
async updateMessageFolders(
@Args('input') input: UpdateMessageFoldersInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.delete(id, workspace.id);
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<MessageFolderDTO[]> {
await Promise.all(
input.ids.map((id) =>
this.messageFolderMetadataService.verifyOwnership({
id,
userWorkspaceId,
workspaceId: workspace.id,
}),
),
);
return this.messageFolderMetadataService.updateMany({
ids: input.ids,
workspaceId: workspace.id,
data: input.update,
});
}
}
@@ -1,9 +1,18 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
import {
MessageFolderException,
MessageFolderExceptionCode,
@@ -16,11 +25,33 @@ export const messageFolderGraphqlApiExceptionHandler = (error: Error) => {
throw new NotFoundError(error);
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
throw new UserInputError(error);
case MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof MessageChannelException) {
switch (error.code) {
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
throw new ForbiddenError(error);
default:
break;
}
}
if (error instanceof ConnectedAccountException) {
switch (error.code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
throw new ForbiddenError(error);
default:
break;
}
}
throw error;
};
@@ -20,7 +20,8 @@ export class MinimalMetadataResolver {
@Query(() => MinimalMetadataDTO)
async minimalMetadata(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<MinimalMetadataDTO> {
return this.minimalMetadataService.getMinimalMetadata(
workspace.id,
@@ -44,7 +44,8 @@ export class NavigationMenuItemResolver {
@UseGuards(NoPermissionGuard)
async navigationMenuItems(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.findAll({
workspaceId: workspace.id,
@@ -69,7 +70,8 @@ export class NavigationMenuItemResolver {
async createNavigationMenuItem(
@Args('input') input: CreateNavigationMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
@@ -87,7 +89,8 @@ export class NavigationMenuItemResolver {
async updateNavigationMenuItem(
@Args('input') input: UpdateOneNavigationMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
@@ -105,7 +108,8 @@ export class NavigationMenuItemResolver {
async deleteNavigationMenuItem(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
@@ -56,7 +56,8 @@ export class ViewController {
async findMany(
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Query('objectMetadataId') objectMetadataId?: string,
): Promise<ViewDTO[]> {
const views = objectMetadataId
@@ -134,7 +135,8 @@ export class ViewController {
@Body() input: UpdateViewInput,
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<ViewDTO> {
const updatedView = await this.viewService.updateOne({
updateViewInput: {
@@ -96,7 +96,8 @@ export class ViewResolver {
@UseGuards(CustomPermissionGuard)
async getViews(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Args('objectMetadataId', { type: () => String, nullable: true })
objectMetadataId?: string,
@Args('viewTypes', { type: () => [ViewType], nullable: true })
@@ -138,7 +139,8 @@ export class ViewResolver {
async createView(
@Args('input') input: CreateViewInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<ViewDTO> {
const visibility = input.visibility ?? ViewVisibility.WORKSPACE;
@@ -157,7 +159,8 @@ export class ViewResolver {
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<ViewDTO> {
return await this.viewService.updateOne({
updateViewInput: { ...input, id },
@@ -58,7 +58,8 @@ export class EventStreamResolver {
@Args('eventStreamId') eventStreamId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
) {
const eventStreamChannelId = eventStreamIdToChannelId(eventStreamId);
@@ -124,7 +125,8 @@ export class EventStreamResolver {
@Args('input') input: AddQuerySubscriptionInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
@@ -168,7 +170,8 @@ export class EventStreamResolver {
@Args('input') input: RemoveQueryFromEventStreamInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
@@ -12,6 +12,7 @@ import { seedAgents } from 'src/engine/workspace-manager/dev-seeder/core/utils/s
import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util';
import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util';
import { seedMetadataEntities } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-metadata-entities.util';
import { seedServerId } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-server-id.util';
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
@@ -93,6 +94,8 @@ export const seedCoreSchema = async ({
await seedBillingSubscriptions({ queryRunner, schemaName, workspaceId });
}
await seedMetadataEntities({ queryRunner, schemaName, workspaceId });
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
@@ -0,0 +1,306 @@
import { type QueryRunner } from 'typeorm';
import {
CalendarChannelVisibility,
MessageChannelSyncStage,
MessageChannelType,
MessageChannelVisibility,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { CALENDAR_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/calendar-channel-data-seeds.constant';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import { MESSAGE_FOLDER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
type SeedMetadataEntitiesArgs = {
queryRunner: QueryRunner;
schemaName: string;
workspaceId: string;
};
export const seedMetadataEntities = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
if (
workspaceId !== SEED_APPLE_WORKSPACE_ID &&
workspaceId !== SEED_YCOMBINATOR_WORKSPACE_ID
) {
return;
}
if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
return;
}
await seedConnectedAccounts({ queryRunner, schemaName, workspaceId });
await seedMessageChannels({ queryRunner, schemaName, workspaceId });
await seedCalendarChannels({ queryRunner, schemaName, workspaceId });
await seedMessageFolders({ queryRunner, schemaName, workspaceId });
};
const seedConnectedAccounts = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const connectedAccounts = [
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.PHIL,
handle: 'phil.schiler@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.PHIL,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE,
handle: 'jane-deletable@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.connectedAccount`, [
'id',
'handle',
'provider',
'userWorkspaceId',
'workspaceId',
])
.orIgnore()
.values(connectedAccounts)
.execute();
};
const seedMessageChannels = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const messageChannels = [
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.messageChannel`, [
'id',
'handle',
'visibility',
'type',
'syncStage',
'isContactAutoCreationEnabled',
'contactAutoCreationPolicy',
'messageFolderImportPolicy',
'excludeNonProfessionalEmails',
'excludeGroupEmails',
'pendingGroupEmailsAction',
'isSyncEnabled',
'connectedAccountId',
'workspaceId',
])
.orIgnore()
.values(messageChannels)
.execute();
};
const seedCalendarChannels = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const calendarChannels = [
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
visibility: CalendarChannelVisibility.METADATA,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JONY,
handle: 'jony@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.calendarChannel`, [
'id',
'handle',
'visibility',
'syncStage',
'isContactAutoCreationEnabled',
'contactAutoCreationPolicy',
'isSyncEnabled',
'connectedAccountId',
'workspaceId',
])
.orIgnore()
.values(calendarChannels)
.execute();
};
const seedMessageFolders = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const messageFolders = [
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.messageFolder`, [
'id',
'name',
'isSynced',
'isSentFolder',
'messageChannelId',
'workspaceId',
'pendingSyncAction',
])
.orIgnore()
.values(messageFolders)
.execute();
};
@@ -26,6 +26,7 @@ const GENERATE_CALENDAR_CHANNEL_IDS = (): Record<string, string> => {
CHANNEL_IDS['TIM'] = '20202020-a40f-4faf-bb9f-c6f9945b8203';
CHANNEL_IDS['JONY'] = '20202020-a40f-4faf-bb9f-c6f9945b8204';
CHANNEL_IDS['PHIL'] = '20202020-a40f-4faf-bb9f-c6f9945b8205';
CHANNEL_IDS['JANE'] = '20202020-a40f-4faf-bb9f-c6f9945b8208';
CHANNEL_IDS['COMPANY_MAIN'] = '20202020-a40f-4faf-bb9f-c6f9945b8206';
CHANNEL_IDS['TEAM_CALENDAR'] = '20202020-a40f-4faf-bb9f-c6f9945b8207';
@@ -59,6 +60,14 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.COMPANY_MAIN,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
@@ -31,6 +31,8 @@ export const CONNECTED_ACCOUNT_DATA_SEED_IDS = {
TIM: '20202020-9ac0-4390-9a1a-ab4d2c4e1bb7',
JONY: '20202020-0cc8-4d60-a3a4-803245698908',
PHIL: '20202020-cafc-4323-908d-e5b42ad69fdf',
JANE: '20202020-b5c7-46f0-bf5c-3f4e4b3f7c1a',
JANE_DELETABLE: '20202020-d1e5-4a8f-9c3b-7f6d5e4c3b2a',
};
export const CONNECTED_ACCOUNT_DATA_SEEDS: ConnectedAccountDataSeed[] = [
@@ -70,4 +72,28 @@ export const CONNECTED_ACCOUNT_DATA_SEEDS: ConnectedAccountDataSeed[] = [
provider: 'google',
handle: 'phil.schiler@apple.dev',
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
lastSyncHistoryId: 'exampleLastSyncHistory',
accountOwnerId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
refreshToken: 'exampleRefreshToken',
accessToken: 'exampleAccessToken',
provider: 'google',
handle: 'jane.austen@apple.dev',
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
lastSyncHistoryId: 'exampleLastSyncHistory',
accountOwnerId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
refreshToken: 'exampleRefreshToken',
accessToken: 'exampleAccessToken',
provider: 'google',
handle: 'jane-deletable@apple.dev',
},
];
@@ -40,6 +40,7 @@ const GENERATE_MESSAGE_CHANNEL_IDS = (): Record<string, string> => {
CHANNEL_IDS['TIM'] = '20202020-9b80-4c2c-a597-383db48de1d6';
CHANNEL_IDS['JONY'] = '20202020-5ffe-4b32-814a-983d5e4911cd';
CHANNEL_IDS['PHIL'] = '20202020-e2f1-49b5-85d2-5d3a3386990c';
CHANNEL_IDS['JANE'] = '20202020-8c4d-4e71-a672-2e6a8c9f1b3d';
CHANNEL_IDS['SUPPORT'] = '20202020-e2f1-49b5-85d2-5d3a3386990d';
CHANNEL_IDS['SALES'] = '20202020-e2f1-49b5-85d2-5d3a3386990e';
@@ -88,6 +89,19 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
isSyncEnabled: true,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.SUPPORT,
createdAt: new Date(),
@@ -0,0 +1,94 @@
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
type MessageFolderDataSeed = {
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
name: string;
isSynced: boolean;
isSentFolder: boolean;
messageChannelId: string;
pendingSyncAction: MessageFolderPendingSyncAction;
};
export const MESSAGE_FOLDER_DATA_SEED_COLUMNS: (keyof MessageFolderDataSeed)[] =
[
'id',
'createdAt',
'updatedAt',
'deletedAt',
'name',
'isSynced',
'isSentFolder',
'messageChannelId',
'pendingSyncAction',
];
export const MESSAGE_FOLDER_DATA_SEED_IDS = {
TIM_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d0e',
TIM_SENT: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d1e',
JONY_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d2e',
JANE_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d3e',
JANE_SENT: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d4e',
};
export const MESSAGE_FOLDER_DATA_SEEDS: MessageFolderDataSeed[] = [
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_SENT,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
@@ -56,6 +56,10 @@ import {
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import {
MESSAGE_FOLDER_DATA_SEED_COLUMNS,
MESSAGE_FOLDER_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
import {
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEEDS,
@@ -194,8 +198,13 @@ const getRecordSeedsBatches = (
},
];
// Batch 4: Depends on person/company or independent
// Batch 4: Depends on person/company/messageChannel or independent
const batch4: RecordSeedConfig[] = [
{
tableName: 'messageFolder',
pgColumns: MESSAGE_FOLDER_DATA_SEED_COLUMNS,
recordSeeds: MESSAGE_FOLDER_DATA_SEEDS,
},
{
tableName: 'opportunity',
pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS,
@@ -0,0 +1,227 @@
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
const mockCalendarChannelSyncStatusService = {
markAsCalendarEventListFetchOngoing: jest.fn(),
markAsCalendarEventListFetchPending: jest.fn(),
markAsCalendarEventsImportPending: jest.fn(),
};
const mockGetCalendarEventsService = {
getCalendarEvents: jest.fn(),
};
const mockCalendarChannelDataAccessService = {
update: jest.fn(),
};
const mockCalendarAccountAuthenticationService = {
validateAndRefreshConnectedAccountAuthentication: jest
.fn()
.mockResolvedValue({
accessToken: 'fresh-access-token',
refreshToken: 'fresh-refresh-token',
}),
};
const mockCalendarEventsImportService = {
processCalendarEventsImport: jest.fn(),
};
const mockCalendarEventImportErrorHandlerService = {
handleDriverException: jest.fn(),
};
const mockCacheStorage = {
setAdd: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<void>) => {
await callback();
}),
};
const workspaceId = 'workspace-123';
const baseConnectedAccount = {
id: 'account-123',
provider: 'google',
refreshToken: 'refresh-token',
accessToken: 'access-token',
handle: 'test@example.com',
} as unknown as ConnectedAccountWorkspaceEntity;
const createCalendarChannel = (
syncCursor: string | null,
): CalendarChannelWorkspaceEntity =>
({
id: 'channel-123',
syncCursor,
connectedAccountId: 'account-123',
}) as unknown as CalendarChannelWorkspaceEntity;
describe('CalendarFetchEventsService', () => {
let service: CalendarFetchEventsService;
beforeEach(() => {
jest.clearAllMocks();
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
fullEvents: true,
calendarEvents: [{ id: 'event-1' }],
nextSyncCursor: 'new-cursor-abc',
});
service = new CalendarFetchEventsService(
mockCacheStorage as any,
mockGlobalWorkspaceOrmManager as any,
mockCalendarChannelDataAccessService as any,
mockCalendarChannelSyncStatusService as any,
mockGetCalendarEventsService as any,
mockCalendarEventImportErrorHandlerService as any,
mockCalendarEventsImportService as any,
mockCalendarAccountAuthenticationService as any,
);
});
describe('syncCursor handling (backwards compatibility)', () => {
it('should perform full sync when syncCursor is null (core schema)', async () => {
const calendarChannel = createCalendarChannel(null);
await service.fetchCalendarEvents(
calendarChannel,
baseConnectedAccount,
workspaceId,
);
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'test@example.com' }),
undefined,
);
});
it('should perform full sync when syncCursor is empty string (workspace schema)', async () => {
const calendarChannel = createCalendarChannel('');
await service.fetchCalendarEvents(
calendarChannel,
baseConnectedAccount,
workspaceId,
);
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'test@example.com' }),
undefined,
);
});
it('should use existing syncCursor for incremental sync', async () => {
const calendarChannel = createCalendarChannel('cursor-xyz');
await service.fetchCalendarEvents(
calendarChannel,
baseConnectedAccount,
workspaceId,
);
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'test@example.com' }),
'cursor-xyz',
);
});
it('should update syncCursor after successful fetch regardless of initial cursor state', async () => {
const calendarChannel = createCalendarChannel(null);
await service.fetchCalendarEvents(
calendarChannel,
baseConnectedAccount,
workspaceId,
);
expect(mockCalendarChannelDataAccessService.update).toHaveBeenCalledWith(
workspaceId,
{ id: 'channel-123' },
{ syncCursor: 'new-cursor-abc' },
);
});
it('should not throw when feature flag switches from off (workspace empty string) to on (core null)', async () => {
// Simulate flag OFF: workspace returns empty string
const workspaceChannel = createCalendarChannel('');
await expect(
service.fetchCalendarEvents(
workspaceChannel,
baseConnectedAccount,
workspaceId,
),
).resolves.not.toThrow();
jest.clearAllMocks();
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
fullEvents: true,
calendarEvents: [{ id: 'event-2' }],
nextSyncCursor: 'new-cursor-def',
});
// Simulate flag ON: core returns null
const coreChannel = createCalendarChannel(null);
await expect(
service.fetchCalendarEvents(
coreChannel,
baseConnectedAccount,
workspaceId,
),
).resolves.not.toThrow();
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(expect.anything(), undefined);
});
it('should preserve incremental sync behavior when toggling feature flag with existing cursor', async () => {
// Flag OFF: workspace has cursor
const workspaceChannel = createCalendarChannel('cursor-from-workspace');
await service.fetchCalendarEvents(
workspaceChannel,
baseConnectedAccount,
workspaceId,
);
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(expect.anything(), 'cursor-from-workspace');
jest.clearAllMocks();
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
fullEvents: true,
calendarEvents: [{ id: 'event-3' }],
nextSyncCursor: 'newer-cursor',
});
// Flag ON: core has same cursor (dual-written)
const coreChannel = createCalendarChannel('cursor-from-workspace');
await service.fetchCalendarEvents(
coreChannel,
baseConnectedAccount,
workspaceId,
);
expect(
mockGetCalendarEventsService.getCalendarEvents,
).toHaveBeenCalledWith(expect.anything(), 'cursor-from-workspace');
});
});
});
@@ -1,7 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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';
@@ -71,17 +69,10 @@ export class CalendarFetchEventsService {
refreshToken,
};
if (!isDefined(calendarChannel.syncCursor)) {
throw new CalendarEventImportDriverException(
'Sync cursor is required',
CalendarEventImportDriverExceptionCode.SYNC_CURSOR_ERROR,
);
}
const getCalendarEventsResponse =
await this.getCalendarEventsService.getCalendarEvents(
connectedAccountWithFreshTokens,
calendarChannel.syncCursor,
calendarChannel.syncCursor || undefined,
);
const hasFullEvents = getCalendarEventsResponse.fullEvents;
@@ -0,0 +1,126 @@
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { CALENDAR_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/calendar-channel-data-seeds.constant';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
describe('calendarChannelResolver (e2e)', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: false,
expectToFail: false,
});
});
describe('myCalendarChannels', () => {
it('should return only the current user calendar channels', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyCalendarChannels {
myCalendarChannels {
id
handle
visibility
syncStatus
syncStage
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const channels = response.body.data.myCalendarChannels;
const channelIds = channels.map(
(channel: { id: string }) => channel.id,
);
expect(channelIds).toContain(CALENDAR_CHANNEL_DATA_SEED_IDS.JANE);
expect(channelIds).not.toContain(
CALENDAR_CHANNEL_DATA_SEED_IDS.JONY,
);
});
it('should deny filtering by another user connectedAccountId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyCalendarChannels($connectedAccountId: UUID) {
myCalendarChannels(connectedAccountId: $connectedAccountId) {
id
}
}
`,
variables: {
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
describe('updateCalendarChannel', () => {
it('should allow updating own channel', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateCalendarChannel(
$input: UpdateCalendarChannelInput!
) {
updateCalendarChannel(input: $input) {
id
visibility
}
}
`,
variables: {
input: {
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
update: { visibility: 'METADATA' },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateCalendarChannel.visibility).toBe(
'METADATA',
);
});
it('should deny updating another user channel', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateCalendarChannel(
$input: UpdateCalendarChannelInput!
) {
updateCalendarChannel(input: $input) {
id
}
}
`,
variables: {
input: {
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JONY,
update: { visibility: 'METADATA' },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
});
@@ -0,0 +1,188 @@
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
describe('connectedAccountResolver (e2e)', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: false,
expectToFail: false,
});
});
describe('myConnectedAccounts', () => {
it('should return only the current user connected accounts', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyConnectedAccounts {
myConnectedAccounts {
id
handle
provider
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const accounts = response.body.data.myConnectedAccounts;
const accountIds = accounts.map(
(account: { id: string }) => account.id,
);
expect(accountIds).toContain(CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE);
expect(accountIds).not.toContain(
CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
);
});
it('should not return sensitive fields', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyConnectedAccounts {
myConnectedAccounts {
id
handle
provider
authFailedAt
scopes
handleAliases
lastSignedInAt
userWorkspaceId
createdAt
updatedAt
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const account = response.body.data.myConnectedAccounts.find(
(connectedAccount: { id: string }) =>
connectedAccount.id === CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
);
expect(account).toBeDefined();
expect(account.handle).toBe('jane.austen@apple.dev');
expect(account).not.toHaveProperty('accessToken');
expect(account).not.toHaveProperty('refreshToken');
expect(account).not.toHaveProperty('connectionParameters');
expect(account).not.toHaveProperty('oidcTokenClaims');
});
it('should reject requesting hidden fields via GraphQL', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyConnectedAccounts {
myConnectedAccounts {
id
accessToken
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBeGreaterThan(0);
});
});
describe('connectedAccounts (admin listing)', () => {
it('should return all workspace accounts for admin', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query ConnectedAccounts {
connectedAccounts {
id
handle
provider
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const accounts = response.body.data.connectedAccounts;
const accountIds = accounts.map(
(account: { id: string }) => account.id,
);
expect(accountIds).toContain(CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE);
expect(accountIds).toContain(CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY);
});
it('should also be accessible for member role (tool permission)', async () => {
const response = await makeMetadataAPIRequestWithMemberRole({
query: gql`
query ConnectedAccounts {
connectedAccounts {
id
handle
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.connectedAccounts).toBeDefined();
});
});
describe('deleteConnectedAccount', () => {
it('should allow deleting own account', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation DeleteConnectedAccount($id: UUID!) {
deleteConnectedAccount(id: $id) {
id
}
}
`,
variables: { id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE },
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.deleteConnectedAccount.id).toBe(
CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE,
);
});
it('should deny deleting another user account', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation DeleteConnectedAccount($id: UUID!) {
deleteConnectedAccount(id: $id) {
id
}
}
`,
variables: { id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY },
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
});
@@ -0,0 +1,163 @@
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
describe('messageChannelResolver (e2e)', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: false,
expectToFail: false,
});
});
describe('myMessageChannels', () => {
it('should return only the current user message channels', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageChannels {
myMessageChannels {
id
handle
visibility
syncStatus
syncStage
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const channels = response.body.data.myMessageChannels;
const channelIds = channels.map(
(channel: { id: string }) => channel.id,
);
expect(channelIds).toContain(MESSAGE_CHANNEL_DATA_SEED_IDS.JANE);
expect(channelIds).not.toContain(MESSAGE_CHANNEL_DATA_SEED_IDS.JONY);
});
it('should filter by connectedAccountId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageChannels($connectedAccountId: UUID) {
myMessageChannels(connectedAccountId: $connectedAccountId) {
id
handle
}
}
`,
variables: {
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const channels = response.body.data.myMessageChannels;
const channelIds = channels.map(
(channel: { id: string }) => channel.id,
);
expect(channelIds).toContain(MESSAGE_CHANNEL_DATA_SEED_IDS.JANE);
expect(channelIds).not.toContain(MESSAGE_CHANNEL_DATA_SEED_IDS.JONY);
});
it('should deny filtering by another user connectedAccountId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageChannels($connectedAccountId: UUID) {
myMessageChannels(connectedAccountId: $connectedAccountId) {
id
}
}
`,
variables: {
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
it('should not return syncCursor', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageChannels {
myMessageChannels {
id
syncCursor
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
});
});
describe('updateMessageChannel', () => {
it('should allow updating own channel settings', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageChannel($input: UpdateMessageChannelInput!) {
updateMessageChannel(input: $input) {
id
visibility
}
}
`,
variables: {
input: {
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
update: { visibility: 'METADATA' },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateMessageChannel.visibility).toBe(
'METADATA',
);
});
it('should deny updating another user channel', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageChannel($input: UpdateMessageChannelInput!) {
updateMessageChannel(input: $input) {
id
}
}
`,
variables: {
input: {
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
update: { visibility: 'METADATA' },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
});
@@ -0,0 +1,161 @@
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import { MESSAGE_FOLDER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
describe('messageFolderResolver (e2e)', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: false,
expectToFail: false,
});
});
describe('myMessageFolders', () => {
it('should return only the current user message folders', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders {
myMessageFolders {
id
name
isSynced
messageChannelId
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const folders = response.body.data.myMessageFolders;
const folderIds = folders.map((folder: { id: string }) => folder.id);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT);
expect(folderIds).not.toContain(
MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
);
});
it('should filter by messageChannelId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
name
}
}
`,
variables: {
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const folders = response.body.data.myMessageFolders;
const folderIds = folders.map((folder: { id: string }) => folder.id);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX);
expect(folderIds).not.toContain(
MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
);
});
it('should deny filtering by another user messageChannelId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
}
}
`,
variables: {
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
it('should not expose hidden fields', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders {
myMessageFolders {
id
syncCursor
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
});
});
describe('updateMessageFolder', () => {
it('should allow updating own folder isSynced', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
isSynced
}
}
`,
variables: {
input: {
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
update: { isSynced: false },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateMessageFolder.isSynced).toBe(false);
});
it('should deny updating another user folder', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
}
}
`,
variables: {
input: {
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
update: { isSynced: false },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
});