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:
+25
-15
@@ -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 (
|
||||
|
||||
+2
-35
@@ -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) => ({
|
||||
|
||||
+28
-34
@@ -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 (
|
||||
|
||||
+7
-49
@@ -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
|
||||
|
||||
+22
-1
@@ -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 (
|
||||
|
||||
+40
@@ -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;
|
||||
};
|
||||
+4
-17
@@ -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) =>
|
||||
|
||||
+21
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+10
-4
@@ -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),
|
||||
|
||||
+10
-2
@@ -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));
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_CONNECTED_ACCOUNT = gql`
|
||||
mutation DeleteConnectedAccount($id: UUID!) {
|
||||
deleteConnectedAccount(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_MESSAGE_FOLDER = gql`
|
||||
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
|
||||
updateMessageFolder(input: $input) {
|
||||
id
|
||||
isSynced
|
||||
}
|
||||
}
|
||||
`;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_MESSAGE_FOLDERS = gql`
|
||||
mutation UpdateMessageFolders($input: UpdateMessageFoldersInput!) {
|
||||
updateMessageFolders(input: $input) {
|
||||
id
|
||||
isSynced
|
||||
}
|
||||
}
|
||||
`;
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+17
@@ -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,
|
||||
};
|
||||
};
|
||||
+36
-2
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user