From a942642b83cc124a5266f7200f303e59c8d965ef Mon Sep 17 00:00:00 2001 From: Guillim Date: Mon, 12 May 2025 15:54:56 +0200 Subject: [PATCH] Untitled records for CreatedBy (#11914) # Display "Soft-Deleted Workspace Members" in Actor Field Display Reminder of the issue : Screenshot 2025-05-07 at 12 11 59 - `ActorFieldDisplay` component : display soft-deleted members - `UserService` includes soft-deleted records when fetching workspace members. This is the tricky part : do we want that for all workspace members or maybe i could create another property dedicated to workspace members and softdeleted ones. To be discussed Result looks like this (we loose the source and the context in this impleentation) Screenshot 2025-05-07 at 12 05 28 Fixes https://github.com/twentyhq/twenty/issues/11870 Another way we could also get into : We could also, when a workspace user is softDeleted, change the current implementation : we could avoid to delete the ActorMetadata like CreatedByName (and context and source) in the "Person" table. It would look more like this Screenshot 2025-05-07 at 12 06 16 However, this implementation is requires more work, and IMO harder to maintain since is decouples completely the record from the workspace member. This could be an issue in case we want tohard delete a user, or decide another logic to display the Actor name. Since the usecase should be pretty rare, I chose the first one but willing to discuss it --------- Co-authored-by: prastoin --- .../src/generated-metadata/graphql.ts | 10 +++ .../twenty-front/src/generated/graphql.tsx | 31 ++++++++- .../modules/auth/components/AuthProvider.tsx | 11 +++- .../src/modules/auth/contexts/AuthContext.ts | 2 + .../currentWorkspaceDeletedMembersStates.ts | 9 +++ .../display/components/ActorFieldDisplay.tsx | 19 ++---- .../perf/ActorFieldDisplay.perf.stories.tsx | 10 +-- .../meta-types/hooks/useActorFieldDisplay.ts | 39 ++++++++--- .../users/components/UserProviderEffect.tsx | 10 +++ .../graphql/fragments/userQueryFragment.ts | 5 ++ .../deletedWorkspaceMemberQueryFragment.ts | 13 ++++ .../decorators/AuthContextDecorator.tsx | 16 +++++ .../user/dtos/deleted-workspace-member.dto.ts | 24 +++++++ ...ted-workspace-member-transpiler.service.ts | 64 +++++++++++++++++++ .../user/services/user.service.ts | 23 ++++++- .../engine/core-modules/user/user.module.ts | 10 ++- .../engine/core-modules/user/user.resolver.ts | 44 +++++++++---- 17 files changed, 294 insertions(+), 46 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/states/currentWorkspaceDeletedMembersStates.ts create mode 100644 packages/twenty-front/src/modules/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment.ts create mode 100644 packages/twenty-front/src/testing/decorators/AuthContextDecorator.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/user/dtos/deleted-workspace-member.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 03f96f97e..ed38f848b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -591,6 +591,15 @@ export type DeleteWorkflowVersionStepInput = { workflowVersionId: Scalars['String']['input']; }; +export type DeletedWorkspaceMember = { + __typename?: 'DeletedWorkspaceMember'; + avatarUrl?: Maybe; + id: Scalars['UUID']['output']; + name: FullName; + userEmail: Scalars['String']['output']; + userWorkspaceId?: Maybe; +}; + /** Schema update on a table */ export enum DistantTableUpdate { COLUMNS_ADDED = 'COLUMNS_ADDED', @@ -2426,6 +2435,7 @@ export type User = { currentWorkspace?: Maybe; defaultAvatarUrl?: Maybe; deletedAt?: Maybe; + deletedWorkspaceMembers?: Maybe>; disabled?: Maybe; email: Scalars['String']['output']; firstName: Scalars['String']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2f1c8176f..d245f3e07 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -522,6 +522,15 @@ export type DeleteWorkflowVersionStepInput = { workflowVersionId: Scalars['String']; }; +export type DeletedWorkspaceMember = { + __typename?: 'DeletedWorkspaceMember'; + avatarUrl?: Maybe; + id: Scalars['UUID']; + name: FullName; + userEmail: Scalars['String']; + userWorkspaceId?: Maybe; +}; + /** Schema update on a table */ export enum DistantTableUpdate { COLUMNS_ADDED = 'COLUMNS_ADDED', @@ -2213,6 +2222,7 @@ export type User = { currentWorkspace?: Maybe; defaultAvatarUrl?: Maybe; deletedAt?: Maybe; + deletedWorkspaceMembers?: Maybe>; disabled?: Maybe; email: Scalars['String']; firstName: Scalars['String']; @@ -2915,7 +2925,7 @@ export type OnDbEventSubscriptionVariables = Exact<{ export type OnDbEventSubscription = { __typename?: 'Subscription', onDbEvent: { __typename?: 'OnDbEventDTO', eventDate: string, action: DatabaseEventAction, objectNameSingular: string, updatedFields?: Array | null, record: any } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -2932,7 +2942,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -3030,6 +3040,8 @@ export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> }; +export type DeletedWorkspaceMemberQueryFragmentFragment = { __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }; + export type WorkspaceMemberQueryFragmentFragment = { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }; export type ActivateWorkspaceMutationVariables = Exact<{ @@ -3203,6 +3215,17 @@ export const WorkspaceMemberQueryFragmentFragmentDoc = gql` timeFormat } `; +export const DeletedWorkspaceMemberQueryFragmentFragmentDoc = gql` + fragment DeletedWorkspaceMemberQueryFragment on DeletedWorkspaceMember { + id + name { + firstName + lastName + } + avatarUrl + userEmail +} + `; export const RoleFragmentFragmentDoc = gql` fragment RoleFragment on Role { id @@ -3233,6 +3256,9 @@ export const UserQueryFragmentFragmentDoc = gql` workspaceMembers { ...WorkspaceMemberQueryFragment } + deletedWorkspaceMembers { + ...DeletedWorkspaceMemberQueryFragment + } currentUserWorkspace { settingsPermissions objectRecordsPermissions @@ -3304,6 +3330,7 @@ export const UserQueryFragmentFragmentDoc = gql` userVars } ${WorkspaceMemberQueryFragmentFragmentDoc} +${DeletedWorkspaceMemberQueryFragmentFragmentDoc} ${RoleFragmentFragmentDoc}`; export const GetTimelineCalendarEventsFromCompanyIdDocument = gql` query GetTimelineCalendarEventsFromCompanyId($companyId: UUID!, $page: Int!, $pageSize: Int!) { diff --git a/packages/twenty-front/src/modules/auth/components/AuthProvider.tsx b/packages/twenty-front/src/modules/auth/components/AuthProvider.tsx index ce7c7bea7..c53256fb9 100644 --- a/packages/twenty-front/src/modules/auth/components/AuthProvider.tsx +++ b/packages/twenty-front/src/modules/auth/components/AuthProvider.tsx @@ -2,13 +2,22 @@ import React from 'react'; import { useRecoilValue } from 'recoil'; import { AuthContext } from '@/auth/contexts/AuthContext'; +import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersStates'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; export const AuthProvider = ({ children }: React.PropsWithChildren) => { const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState); + const currentWorkspaceDeletedMembers = useRecoilValue( + currentWorkspaceDeletedMembersState, + ); return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/auth/contexts/AuthContext.ts b/packages/twenty-front/src/modules/auth/contexts/AuthContext.ts index f3cfac5f0..c01443c46 100644 --- a/packages/twenty-front/src/modules/auth/contexts/AuthContext.ts +++ b/packages/twenty-front/src/modules/auth/contexts/AuthContext.ts @@ -1,8 +1,10 @@ import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; import { createContext } from 'react'; +import { DeletedWorkspaceMember } from '~/generated-metadata/graphql'; export type AuthContextType = { currentWorkspaceMembers: CurrentWorkspaceMember[]; + currentWorkspaceDeletedMembers: DeletedWorkspaceMember[]; }; export const AuthContext = createContext( diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceDeletedMembersStates.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceDeletedMembersStates.ts new file mode 100644 index 000000000..bbce85ec4 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceDeletedMembersStates.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui/utilities'; +import { DeletedWorkspaceMember } from '~/generated-metadata/graphql'; + +export const currentWorkspaceDeletedMembersState = createState< + DeletedWorkspaceMember[] +>({ + key: 'currentWorkspaceDeletedMembersState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ActorFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ActorFieldDisplay.tsx index cd176beb4..530b92b58 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ActorFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ActorFieldDisplay.tsx @@ -1,27 +1,22 @@ import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; import { useActorFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useActorFieldDisplay'; import { ActorDisplay } from '@/ui/field/display/components/ActorDisplay'; -import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; export const ActorFieldDisplay = () => { - const { fieldValue } = useActorFieldDisplay(); - - const name = !fieldValue.workspaceMemberId - ? fieldValue.name - : [ - fieldValue.workspaceMember?.name.firstName, - fieldValue.workspaceMember?.name.lastName, - ] - .filter(isNonEmptyString) - .join(' '); + const actorFieldDisplay = useActorFieldDisplay(); const displayActorField = !useIsFieldEmpty(); + if (!isDefined(actorFieldDisplay)) { + return null; + } + const { fieldValue, name, avatarUrl } = actorFieldDisplay; return displayActorField ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/ActorFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/ActorFieldDisplay.perf.stories.tsx index ccd8b3c4e..09f0acdd4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/ActorFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/ActorFieldDisplay.perf.stories.tsx @@ -1,15 +1,17 @@ import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay'; import { Meta, StoryObj } from '@storybook/react'; -import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; -import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; -import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; -import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; import { ComponentDecorator } from 'twenty-ui/testing'; +import { AuthContextDecorator } from '~/testing/decorators/AuthContextDecorator'; +import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; +import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; +import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; +import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; const meta: Meta = { title: 'UI/Data/Field/Display/ActorFieldDisplay', decorators: [ + AuthContextDecorator, MemoryRouterDecorator, ChipGeneratorsDecorator, getFieldDecorator('company', 'createdBy', { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useActorFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useActorFieldDisplay.ts index aa6378e13..e678ce180 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useActorFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useActorFieldDisplay.ts @@ -4,12 +4,20 @@ import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadat import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { AuthContext } from '@/auth/contexts/AuthContext'; +import { isDefined } from 'twenty-shared/utils'; +import { WorkspaceMember } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; -export const useActorFieldDisplay = () => { +export type ActorFieldDisplayValue = { + fieldValue: FieldActorValue; + name: string; +} & Pick; + +export const useActorFieldDisplay = (): ActorFieldDisplayValue | undefined => { const { recordId, fieldDefinition } = useContext(FieldContext); - const { currentWorkspaceMembers } = useContext(AuthContext); + const { currentWorkspaceDeletedMembers, currentWorkspaceMembers } = + useContext(AuthContext); const fieldName = fieldDefinition.metadata.fieldName; @@ -17,14 +25,27 @@ export const useActorFieldDisplay = () => { recordId, fieldName, ); + if (!isDefined(fieldValue)) { + return undefined; + } + const relatedWorkspaceMember = [ + ...currentWorkspaceDeletedMembers, + ...currentWorkspaceMembers, + ].find( + (workspaceMember) => workspaceMember.id === fieldValue.workspaceMemberId, + ); + if (!isDefined(relatedWorkspaceMember)) { + return { + fieldValue, + name: fieldValue.name, + }; + } + + const { name, avatarUrl } = relatedWorkspaceMember; return { - fieldDefinition, - fieldValue: { - ...fieldValue, - workspaceMember: currentWorkspaceMembers?.find( - (member) => member.id === fieldValue?.workspaceMemberId, - ), - }, + fieldValue, + name: `${name.firstName} ${name.lastName}`, + avatarUrl: avatarUrl, }; }; diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index a8229c460..fbe225a99 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -3,6 +3,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersStates'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -45,6 +46,9 @@ export const UserProviderEffect = () => { const setCurrentWorkspaceMembers = useSetRecoilState( currentWorkspaceMembersState, ); + const setCurrentWorkspaceMembersWithDeleted = useSetRecoilState( + currentWorkspaceDeletedMembersState, + ); const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({ skip: @@ -77,6 +81,7 @@ export const UserProviderEffect = () => { const { workspaceMember, workspaceMembers, + deletedWorkspaceMembers, workspaces: userWorkspaces, } = queryData.currentUser; @@ -122,6 +127,10 @@ export const UserProviderEffect = () => { ); } + if (isDefined(deletedWorkspaceMembers)) { + setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers); + } + if (isDefined(userWorkspaces)) { const workspaces = userWorkspaces .map(({ workspace }) => workspace) @@ -141,6 +150,7 @@ export const UserProviderEffect = () => { queryData?.currentUser, setIsCurrentUserLoaded, setDateTimeFormat, + setCurrentWorkspaceMembersWithDeleted, ]); return <>; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index bf9ba8655..fe91bbbc4 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -1,4 +1,5 @@ import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; +import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment'; import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment'; import { gql } from '@apollo/client'; @@ -19,6 +20,9 @@ export const USER_QUERY_FRAGMENT = gql` workspaceMembers { ...WorkspaceMemberQueryFragment } + deletedWorkspaceMembers { + ...DeletedWorkspaceMemberQueryFragment + } currentUserWorkspace { settingsPermissions objectRecordsPermissions @@ -91,4 +95,5 @@ export const USER_QUERY_FRAGMENT = gql` } ${WORKSPACE_MEMBER_QUERY_FRAGMENT} + ${DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment.ts b/packages/twenty-front/src/modules/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment.ts new file mode 100644 index 000000000..f8c0c418f --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT = gql` + fragment DeletedWorkspaceMemberQueryFragment on DeletedWorkspaceMember { + id + name { + firstName + lastName + } + avatarUrl + userEmail + } +`; diff --git a/packages/twenty-front/src/testing/decorators/AuthContextDecorator.tsx b/packages/twenty-front/src/testing/decorators/AuthContextDecorator.tsx new file mode 100644 index 000000000..6bd381d07 --- /dev/null +++ b/packages/twenty-front/src/testing/decorators/AuthContextDecorator.tsx @@ -0,0 +1,16 @@ +import { Decorator } from '@storybook/react'; + +import { AuthContext } from '@/auth/contexts/AuthContext'; + +export const AuthContextDecorator: Decorator = (Story) => { + return ( + + + + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/user/dtos/deleted-workspace-member.dto.ts b/packages/twenty-server/src/engine/core-modules/user/dtos/deleted-workspace-member.dto.ts new file mode 100644 index 000000000..a30239b84 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/dtos/deleted-workspace-member.dto.ts @@ -0,0 +1,24 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { FullName } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; + +@ObjectType() +export class DeletedWorkspaceMember { + @IDField(() => UUIDScalarType) + id: string; + + @Field(() => FullName) + name: FullName; + + @Field({ nullable: false }) + userEmail: string; + + @Field(() => String, { nullable: true }) + avatarUrl: string | null; + + @Field(() => String, { nullable: true }) + userWorkspaceId: string | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service.ts new file mode 100644 index 000000000..c2568cb0d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; + +import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +export class DeletedWorkspaceMemberTranspiler { + constructor(private readonly fileService: FileService) {} + + generateSignedAvatarUrl({ + workspaceId, + workspaceMember, + }: { + workspaceMember: Pick; + workspaceId: string; + }): string { + const avatarUrlToken = this.fileService.encodeFileToken({ + workspaceMemberId: workspaceMember.id, + workspaceId: workspaceId, + }); + + return `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; + } + + toDeletedWorkspaceMemberDto( + workspaceMember: WorkspaceMemberWorkspaceEntity, + userWorkspaceId?: string, + ): DeletedWorkspaceMember { + const { + avatarUrl: avatarUrlFromEntity, + id, + name, + userEmail, + } = workspaceMember; + + const avatarUrl = userWorkspaceId + ? this.generateSignedAvatarUrl({ + workspaceId: userWorkspaceId, + workspaceMember: { + avatarUrl: avatarUrlFromEntity, + id, + }, + }) + : null; + + return { + id, + name, + userEmail, + avatarUrl, + userWorkspaceId: userWorkspaceId ?? null, + } satisfies DeletedWorkspaceMember; + } + + toDeletedWorkspaceMemberDtos( + workspaceMembers: WorkspaceMemberWorkspaceEntity[], + userWorkspaceId?: string, + ): DeletedWorkspaceMember[] { + return workspaceMembers.map((workspaceMember) => + this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId), + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 8c248e4cc..7e0a0fbbe 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -4,7 +4,7 @@ import assert from 'assert'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { @@ -63,7 +63,7 @@ export class UserService extends TypeOrmQueryService { }); } - async loadWorkspaceMembers(workspace: Workspace) { + async loadWorkspaceMembers(workspace: Workspace, withDeleted = false) { if (!isWorkspaceActiveOrSuspended(workspace)) { return []; } @@ -74,7 +74,24 @@ export class UserService extends TypeOrmQueryService { 'workspaceMember', ); - return workspaceMemberRepository.find(); + return await workspaceMemberRepository.find({ withDeleted: withDeleted }); + } + + async loadDeletedWorkspaceMembersOnly(workspace: Workspace) { + if (!isWorkspaceActiveOrSuspended(workspace)) { + return []; + } + + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspace.id, + 'workspaceMember', + ); + + return await workspaceMemberRepository.find({ + where: { deletedAt: Not(IsNull()) }, + withDeleted: true, + }); } private async deleteUserFromWorkspace({ diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 28ac87887..44d0cd725 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -24,6 +24,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -53,7 +54,12 @@ import { UserService } from './services/user.service'; PermissionsModule, UserWorkspaceModule, ], - exports: [UserService], - providers: [UserService, UserResolver, TypeORMService], + exports: [UserService, DeletedWorkspaceMemberTranspiler], + providers: [ + UserService, + UserResolver, + TypeORMService, + DeletedWorkspaceMemberTranspiler, + ], }) export class UserModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index da764abb7..282f99e02 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -34,7 +34,9 @@ import { } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; +import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -79,6 +81,7 @@ export class UserResolver { private readonly userWorkspaceRepository: Repository, private readonly userRoleService: UserRoleService, private readonly permissionsService: PermissionsService, + private readonly deletedWorkspaceMemberTranspiler: DeletedWorkspaceMemberTranspiler, ) {} @Query(() => User) @@ -187,7 +190,7 @@ export class UserResolver { workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; } - // TODO: Fix typing disrepency between Entity and DTO + // TODO Refactor to be transpiled to WorkspaceMember instead return workspaceMember as WorkspaceMember | null; } @@ -195,17 +198,15 @@ export class UserResolver { nullable: true, }) async workspaceMembers( - @Parent() user: User, + @Parent() _user: User, @AuthWorkspace() workspace: Workspace, ): Promise { - const workspaceMemberEntities = - await this.userService.loadWorkspaceMembers(workspace); + const workspaceMemberEntities = await this.userService.loadWorkspaceMembers( + workspace, + false, + ); const workspaceMembers: WorkspaceMember[] = []; - - let userWorkspacesByUserId = new Map(); - let rolesByUserWorkspaces = new Map(); - const userWorkspaces = await this.userWorkspaceRepository.find({ where: { userId: In(workspaceMemberEntities.map((entity) => entity.userId)), @@ -213,21 +214,20 @@ export class UserResolver { }, }); - userWorkspacesByUserId = new Map( + const userWorkspacesByUserId = new Map( userWorkspaces.map((userWorkspace) => [ userWorkspace.userId, userWorkspace, ]), ); - rolesByUserWorkspaces = await this.userRoleService.getRolesByUserWorkspaces( - { + const rolesByUserWorkspaces: Map = + await this.userRoleService.getRolesByUserWorkspaces({ userWorkspaceIds: userWorkspaces.map( (userWorkspace) => userWorkspace.id, ), workspaceId: workspace.id, - }, - ); + }); for (const workspaceMemberEntity of workspaceMemberEntities) { if (workspaceMemberEntity.avatarUrl) { @@ -239,12 +239,14 @@ export class UserResolver { workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`; } + // TODO Refactor to be transpiled to WorkspaceMember instead const workspaceMember = workspaceMemberEntity as WorkspaceMember; const userWorkspace = userWorkspacesByUserId.get( workspaceMemberEntity.userId, ); + // TODO Refactor should not throw ? typed as nullable ? if (!userWorkspace) { throw new Error('User workspace not found'); } @@ -279,6 +281,22 @@ export class UserResolver { return workspaceMembers; } + @ResolveField(() => [DeletedWorkspaceMember], { + nullable: true, + }) + async deletedWorkspaceMembers( + @Parent() _user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const workspaceMemberEntities = + await this.userService.loadDeletedWorkspaceMembersOnly(workspace); + + return this.deletedWorkspaceMemberTranspiler.toDeletedWorkspaceMemberDtos( + workspaceMemberEntities, + workspace.id, + ); + } + @ResolveField(() => String, { nullable: true, })