Fix/workspace member avatars 20193 (#20200)

Fixes #20193

**Bug Description:**
Previously, workspace member avatars failed to render correctly in table
views and relation chips (such as the Account Owner field). While the
avatar picker dropdown correctly fetched fresh GraphQL data, table views
and chips relied on the cached defaultAvatarUrl or avatarUrl fields,
which were frequently resolving to empty strings or failing to parse
external OAuth URLs correctly.

**Root Cause:**

- Empty String Defaults: Deleting an avatar or failing to retrieve one
defaulted the database state to an empty string ("") instead of null,
which caused frontend image components to break rather than render their
fallback states.

- Missing Permanent URLs: The WorkspaceMemberTranspiler was strictly
expecting internal signed URLs. If an avatar was an external OAuth URL,
it incorrectly returned an empty string, breaking SSO profile pictures.

- Missing Fallbacks: New users lacked a proper Gravatar fallback
assignment upon workspace creation.

**Changes Made:**

- user-workspace.service.ts: Updated the avatar computation logic during
user creation to implement a reliable Gravatar fallback and correctly
set missing avatars to null instead of empty strings. Updated the
storage to use permanent file URLs.
- file-url.service.ts: Implemented a getRawFileUrl method to support
rendering permanent, non-expiring file URLs for avatars.
- workspace-member-transpiler.service.ts: Refactored the URL
transpilation logic to gracefully pass through external OAuth URLs
(e.g., Google/Microsoft profile pictures) instead of stripping them.
- WorkspaceMemberPictureUploader.tsx: Fixed the frontend removal logic
so that deleting a profile picture sets the avatarUrl to null
(consistent with the backend) rather than an empty string.

**Testing:**

- Verified that avatars correctly display in relation chips and table
views.
- Verified that external OAuth avatars load properly.
- Verified that deleting an avatar correctly resets the UI to the
fallback initials component.

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kartik Pant
2026-05-05 01:12:12 +05:30
committed by GitHub
parent 8c2885f9ed
commit e6399b180e
31 changed files with 299 additions and 44 deletions
@@ -863,7 +863,6 @@ type User {
firstName: String!
lastName: String!
email: String!
defaultAvatarUrl: String
isEmailVerified: Boolean!
disabled: Boolean
canImpersonate: Boolean!
@@ -648,7 +648,6 @@ export interface User {
firstName: Scalars['String']
lastName: Scalars['String']
email: Scalars['String']
defaultAvatarUrl?: Scalars['String']
isEmailVerified: Scalars['Boolean']
disabled?: Scalars['Boolean']
canImpersonate: Scalars['Boolean']
@@ -3543,7 +3542,6 @@ export interface UserGenqlSelection{
firstName?: boolean | number
lastName?: boolean | number
email?: boolean | number
defaultAvatarUrl?: boolean | number
isEmailVerified?: boolean | number
disabled?: boolean | number
canImpersonate?: boolean | number
@@ -1830,9 +1830,6 @@ export default {
"email": [
1
],
"defaultAvatarUrl": [
1
],
"isEmailVerified": [
6
],
@@ -5482,7 +5482,6 @@ export type User = {
createdAt: Scalars['DateTime'];
currentUserWorkspace?: Maybe<UserWorkspace>;
currentWorkspace?: Maybe<Workspace>;
defaultAvatarUrl?: Maybe<Scalars['String']>;
deletedAt?: Maybe<Scalars['DateTime']>;
deletedWorkspaceMembers?: Maybe<Array<DeletedWorkspaceMember>>;
disabled?: Maybe<Scalars['Boolean']>;
@@ -7235,7 +7234,7 @@ export type UploadWorkspaceMemberProfilePictureMutationVariables = Exact<{
}>;
export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'FileWithSignedUrl', url: string } };
export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'FileWithSignedUrl', id: string, url: string } };
export type UpdateUserEmailMutationVariables = Exact<{
newEmail: Scalars['String'];
@@ -8068,7 +8067,7 @@ export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
export const UpdateWorkspaceMemberSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceMemberSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkspaceMemberSettingsMutation, UpdateWorkspaceMemberSettingsMutationVariables>;
export const CreateOneRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"createRoleInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]} as unknown as DocumentNode<CreateOneRoleMutation, CreateOneRoleMutationVariables>;
@@ -3,6 +3,7 @@ import { gql } from '@apollo/client';
export const UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE = gql`
mutation UploadWorkspaceMemberProfilePicture($file: Upload!) {
uploadWorkspaceMemberProfilePicture(file: $file) {
id
url
}
}
@@ -6,9 +6,12 @@ import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfi
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { UploadWorkspaceMemberProfilePictureDocument } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -32,6 +35,9 @@ export const WorkspaceMemberPictureUploader = ({
useState<AbortController | null>(null);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const [uploadPicture] = useMutation(
UploadWorkspaceMemberProfilePictureDocument,
@@ -66,19 +72,27 @@ export const WorkspaceMemberPictureUploader = ({
},
});
const signedFile = data?.uploadWorkspaceMemberProfilePicture;
if (!isDefined(signedFile)) {
const uploadedFile = data?.uploadWorkspaceMemberProfilePicture;
if (!isDefined(uploadedFile)) {
throw new Error('Avatar upload failed');
}
newAvatarUrl = signedFile.url;
newAvatarUrl = `${REACT_APP_SERVER_BASE_URL}/file/${FileFolder.CorePicture}/${uploadedFile.id}`;
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: newAvatarUrl },
});
const signedUrl = uploadedFile.url;
if (isDefined(signedUrl) && isEditingSelf) {
setCurrentWorkspaceMember((previous) =>
previous ? { ...previous, avatarUrl: signedUrl } : previous,
);
}
if (isDefined(onAvatarUpdated)) {
onAvatarUpdated(newAvatarUrl);
onAvatarUpdated(signedUrl ?? newAvatarUrl);
}
setUploadController(null);
@@ -104,7 +118,7 @@ export const WorkspaceMemberPictureUploader = ({
try {
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: '' },
update: { avatarUrl: null },
});
if (isDefined(onAvatarUpdated)) {
@@ -0,0 +1,21 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.3.0', 1777915958318)
export class RemoveUserDefaultAvatarUrlFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."user" DROP COLUMN "defaultAvatarUrl"',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."user" ADD "defaultAvatarUrl" character varying',
);
}
}
@@ -1,7 +1,18 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillImageIdentifierFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777920000000-backfill-image-identifier-field-metadata-id.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [],
providers: [],
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [BackfillImageIdentifierFieldMetadataIdCommand],
})
export class V2_3_UpgradeVersionCommandModule {}
@@ -0,0 +1,139 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const WORKSPACE_MEMBER_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
const AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.fields.avatarUrl.universalIdentifier;
@RegisteredWorkspaceCommand('2.3.0', 1777920000000)
@Command({
name: 'upgrade:2-3:backfill-image-identifier-field-metadata-id',
description:
'Backfill imageIdentifierFieldMetadataId on workspaceMember for workspaces where it was never set.',
})
export class BackfillImageIdentifierFieldMetadataIdCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const existingObject =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: WORKSPACE_MEMBER_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(existingObject)) {
this.logger.log(
`workspaceMember object not found for workspace ${workspaceId}, skipping`,
);
return;
}
if (
existingObject.imageIdentifierFieldMetadataUniversalIdentifier ===
AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER
) {
this.logger.log(
`imageIdentifierFieldMetadataId already set for workspace ${workspaceId}, skipping`,
);
return;
}
const existingField =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier: AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(existingField)) {
this.logger.log(
`avatarUrl field not found for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would backfill imageIdentifierFieldMetadataId on workspaceMember for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [
{
...existingObject,
imageIdentifierFieldMetadataUniversalIdentifier:
AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER,
},
],
},
},
workspaceId,
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill imageIdentifierFieldMetadataId for workspace ${workspaceId}:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill imageIdentifierFieldMetadataId for workspace ${workspaceId}`,
);
}
this.logger.log(
`Backfilled imageIdentifierFieldMetadataId on workspaceMember for workspace ${workspaceId}`,
);
}
}
@@ -22,6 +22,7 @@ import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/data
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -46,4 +47,5 @@ export const INSTANCE_COMMANDS = [
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
AddDeletedAtToAgentChatThreadFastInstanceCommand,
RemoveUserDefaultAvatarUrlFastInstanceCommand,
];
@@ -26,7 +26,10 @@ export class WorkspaceMemberQueryResultGetterHandler
);
if (!isDefined(fileId)) {
return workspaceMember;
return {
...workspaceMember,
avatarUrl: '',
};
}
const signedUrl = this.fileUrlService.signFileByIdUrl({
@@ -153,7 +153,7 @@ export class AdminPanelStatisticsService {
>();
for (const user of users) {
signedAvatarUrlByUserId.set(user.id, user.defaultAvatarUrl ?? null);
signedAvatarUrlByUserId.set(user.id, null);
for (const userWorkspace of user.userWorkspaces) {
const workspace = userWorkspace.workspace;
@@ -169,7 +169,7 @@ export class AdminPanelStatisticsService {
entry.fallbackAvatarUrlsByUserId.set(
user.id,
userWorkspace.defaultAvatarUrl ?? user.defaultAvatarUrl ?? null,
userWorkspace.defaultAvatarUrl ?? null,
);
contextsByWorkspaceId.set(workspace.id, entry);
}
@@ -44,9 +44,7 @@ export class AdminPanelUserLookupService {
.filter((workspaceUser) => isDefined(workspaceUser.user))
.map((workspaceUser) => [
workspaceUser.user.id,
workspaceUser.defaultAvatarUrl ??
workspaceUser.user.defaultAvatarUrl ??
null,
workspaceUser.defaultAvatarUrl ?? null,
]),
);
}
@@ -5,7 +5,6 @@ export const AUTH_CONTEXT_USER_SELECT_FIELDS = [
'firstName',
'lastName',
'email',
'defaultAvatarUrl',
'isEmailVerified',
'disabled',
'canImpersonate',
@@ -85,7 +85,6 @@ describe('RefreshTokenService', () => {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
defaultAvatarUrl: '',
} as UserEntity;
jest
@@ -66,4 +66,16 @@ export class FileUrlService {
return `${serverUrl}/file/${fileFolder}/${fileId}?token=${token}`;
}
getLegacyWorkspaceMemberAvatarUrl({
fileId,
fileFolder,
}: {
fileId: string;
fileFolder: FileFolder;
}): string {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return `${serverUrl}/file/${fileFolder}/${fileId}`;
}
}
@@ -61,7 +61,6 @@ describe('TwoFactorAuthenticationResolver', () => {
firstName: '',
lastName: '',
email: 'test@example.com',
defaultAvatarUrl: '',
isEmailVerified: true,
disabled: false,
canImpersonate: false,
@@ -248,7 +248,6 @@ describe('UserWorkspaceService', () => {
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
defaultAvatarUrl: 'avatar-url',
locale: 'en',
isEmailVerified: false,
disabled: false,
@@ -162,7 +162,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
colorScheme: 'System',
userId: user.id,
userEmail: user.email,
avatarUrl: userWorkspace.defaultAvatarUrl ?? '',
avatarUrl: userWorkspace.defaultAvatarUrl ?? null,
locale: (user.locale ?? SOURCE_LOCALE) as keyof typeof APP_LOCALES,
});
@@ -502,7 +502,10 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
queryRunner,
});
return savedFile.url;
return this.fileUrlService.getLegacyWorkspaceMemberAvatarUrl({
fileId: savedFile.id,
fileFolder: FileFolder.CorePicture,
});
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
return;
@@ -523,7 +526,14 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
},
);
return savedFile?.url;
if (!isDefined(savedFile)) {
return;
}
return this.fileUrlService.getLegacyWorkspaceMemberAvatarUrl({
fileId: savedFile.id,
fileFolder: FileFolder.CorePicture,
});
}
castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
@@ -58,10 +58,6 @@ export class UserEntity {
@Column()
email: string;
@Field({ nullable: true })
@Column({ nullable: true })
defaultAvatarUrl: string;
@Field()
@Column({ default: false })
isEmailVerified: boolean;
@@ -6,7 +6,6 @@ export const fromUserEntityToFlat = (entity: UserEntity): FlatUser => ({
firstName: entity.firstName,
lastName: entity.lastName,
email: entity.email,
defaultAvatarUrl: entity.defaultAvatarUrl,
isEmailVerified: entity.isEmailVerified,
disabled: entity.disabled,
canImpersonate: entity.canImpersonate,
@@ -145,6 +145,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"labelIdentifierFieldMetadataUniversalIdentifier",
"standardOverrides",
"isSearchable",
"imageIdentifierFieldMetadataUniversalIdentifier",
],
"propertiesToStringify": [
"standardOverrides",
@@ -242,9 +242,10 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
},
imageIdentifierFieldMetadataId: {
toCompare: false,
toCompare: true,
toStringify: false,
universalProperty: undefined,
// @ts-expect-error remove once https://github.com/twentyhq/core-team-issues/issues/2172 has been resolved
universalProperty: 'imageIdentifierFieldMetadataUniversalIdentifier',
},
targetTableName: {
toCompare: false,
@@ -49,6 +49,8 @@ type Assertions = [
| 'labelPlural'
| 'labelIdentifierFieldMetadataId'
| 'labelIdentifierFieldMetadataUniversalIdentifier'
| 'imageIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataUniversalIdentifier'
| 'isSearchable'
>
>,
@@ -58,6 +60,7 @@ type Assertions = [
FlatEntityUpdate<'objectMetadata'>,
{
labelIdentifierFieldMetadataUniversalIdentifier?: never;
imageIdentifierFieldMetadataUniversalIdentifier?: never;
}
>
>,
@@ -61,6 +61,14 @@ export const createStandardObjectFlatMetadata = <
labelIdentifierFieldMetadataName
].universalIdentifier;
const imageIdentifierFieldMetadataUniversalIdentifier =
imageIdentifierFieldMetadataName
? // @ts-expect-error ignore
STANDARD_OBJECTS[nameSingular as keyof typeof STANDARD_OBJECTS].fields[
imageIdentifierFieldMetadataName
].universalIdentifier
: null;
return {
universalIdentifier,
applicationId: twentyStandardApplicationId,
@@ -108,6 +116,6 @@ export const createStandardObjectFlatMetadata = <
viewUniversalIdentifiers: [],
indexMetadataUniversalIdentifiers: [],
labelIdentifierFieldMetadataUniversalIdentifier,
imageIdentifierFieldMetadataUniversalIdentifier: null,
imageIdentifierFieldMetadataUniversalIdentifier,
};
};
@@ -35,6 +35,7 @@ type Assertions = [
| 'labelSingular'
| 'labelPlural'
| 'labelIdentifierFieldMetadataUniversalIdentifier'
| 'imageIdentifierFieldMetadataUniversalIdentifier'
| 'isSearchable'
>
>,
@@ -56,8 +56,11 @@ export class UpdateObjectActionHandlerService extends WorkspaceMigrationRunnerAc
});
// TODO remove once https://github.com/twentyhq/core-team-issues/issues/2172 has been resolved
const { labelIdentifierFieldMetadataUniversalIdentifier, ...restUpdate } =
action.update;
const {
labelIdentifierFieldMetadataUniversalIdentifier,
imageIdentifierFieldMetadataUniversalIdentifier,
...restUpdate
} = action.update;
const transpiledUpdate: FlatEntityUpdate<'objectMetadata'> = {
...restUpdate,
@@ -79,6 +82,22 @@ export class UpdateObjectActionHandlerService extends WorkspaceMigrationRunnerAc
transpiledUpdate.labelIdentifierFieldMetadataId = flatFieldMetadata.id;
}
if (isDefined(imageIdentifierFieldMetadataUniversalIdentifier)) {
const flatFieldMetadata = findFlatEntityByUniversalIdentifier({
flatEntityMaps: allFlatEntityMaps.flatFieldMetadataMaps,
universalIdentifier: imageIdentifierFieldMetadataUniversalIdentifier,
});
if (!isDefined(flatFieldMetadata)) {
throw new FlatEntityMapsException(
`Could not resolve imageIdentifierFieldMetadataUniversalIdentifier to imageIdentifierFieldMetadataId: no fieldMetadata found for universal identifier ${imageIdentifierFieldMetadataUniversalIdentifier}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
transpiledUpdate.imageIdentifierFieldMetadataId = flatFieldMetadata.id;
}
return {
type: 'update',
metadataName: 'objectMetadata',
@@ -7,7 +7,6 @@ exports[`Successful User Sign Up (integration) should sign up, delete and signup
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"defaultAvatarUrl": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
@@ -41,7 +40,6 @@ exports[`Successful User Sign Up (integration) should sign up, delete and signup
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"defaultAvatarUrl": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
@@ -24,7 +24,6 @@ export const getCurrentUser = async ({
email
firstName
lastName
defaultAvatarUrl
isEmailVerified
disabled
canImpersonate
@@ -1,4 +1,21 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Object metadata update should fail when imageIdentifier is not a uuid 1`] = `
[
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-uuid",
},
"message": "Invalid UUID: 'not-a-uuid'",
"name": "ValidationError",
},
]
`;
exports[`Object metadata update should fail when labelIdentifier is not a TEXT or NAME field 1`] = `
[
@@ -43,7 +43,20 @@ const labelIdentifierFailingTestsUseCase: CreateOneObjectMetadataItemTestingCont
},
];
const allTestsUseCases = [...labelIdentifierFailingTestsUseCase];
const imageIdentifierFailingTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[
{
title: 'when imageIdentifier is not a uuid',
context: {
imageIdentifierFieldMetadataId: 'not-a-uuid',
},
},
];
const allTestsUseCases = [
...labelIdentifierFailingTestsUseCase,
...imageIdentifierFailingTestsUseCase,
];
describe('Object metadata update should fail', () => {
let objectMetadataId: string;