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
@@ -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 };