From f4ff234db8536e0b41abded8e998931b76aca2ed Mon Sep 17 00:00:00 2001
From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:15:47 +0530
Subject: [PATCH] feat: make record avatar/icon resolution data-driven via a
configurable image identifier field (#22644)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).
An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.
This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.
## ❓ Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).
### ✅ Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.
---
.../components/MentionMenuListItem.tsx | 11 +-
...ecordIdentifierToObjectRecordIdentifier.ts | 2 +-
.../components/LinkIconWithLinkOverlay.tsx | 3 +-
...ectRecordIdentifierByNameSingular.test.tsx | 2 +-
.../getImageIdentifierFieldValue.test.ts | 90 +++++
.../object-metadata/utils/getAvatarType.ts | 36 +-
.../object-metadata/utils/getAvatarUrl.ts | 20 +-
.../getImageIdentifierFieldMetadataItem.ts | 14 +-
.../utils/getImageIdentifierFieldValue.ts | 42 ++-
.../utils/getObjectRecordIdentifier.ts | 9 +-
.../utils/isImageIdentifierField.ts | 29 --
...epthRecordGqlFieldsFromObject.test.ts.snap | 1 +
.../MultipleRecordPickerMenuItemContent.tsx | 2 +-
.../components/SingleRecordPickerMenuItem.tsx | 25 +-
.../record-show/components/SummaryCard.tsx | 14 +-
.../hooks/usePersonAvatarUpload.ts | 59 ---
.../hooks/useRecordImageIdentifierUpload.ts | 82 ++++
.../utils/getRecordChipGenerators.ts | 9 +-
...SettingsDataModelObjectIdentifiersForm.tsx | 73 ++--
.../data/companies/mock-companies-data.ts | 50 ++-
.../generated/data/tasks/mock-tasks-data.ts | 10 +
.../mock-timelineActivities-data.ts | 70 ++++
.../metadata/objects/mock-objects-metadata.ts | 6 +-
.../2-22-upgrade-version-command.module.ts | 26 ++
...ge-identifier-field-metadata-id.command.ts | 175 +++++++++
...erson-avatar-url-to-avatar-file.command.ts | 350 ++++++++++++++++++
.../workspace-command-provider.module.ts | 2 +
.../__mocks__/mockFlatObjectMetadatas.ts | 21 +-
.../timeline-calendar-event.module.ts | 2 +
.../timeline-calendar-event.service.spec.ts | 119 ++++++
.../timeline-calendar-event.service.ts | 147 ++++----
.../services/file-core-picture.service.ts | 15 +-
.../file/file-url/file-url.service.ts | 21 ++
.../services/timeline-messaging.service.ts | 68 ++--
.../messaging/timeline-messaging.module.ts | 2 +
.../get-record-image-identifier.util.spec.ts | 232 ++++++++++++
.../utils/get-record-image-identifier.util.ts | 76 ++--
.../search/__tests__/search.service.spec.ts | 20 +-
.../search/services/search.service.ts | 165 ++++++---
...nslatable-properties.constant.spec.ts.snap | 1 +
...configuration-by-metadata-name.constant.ts | 1 +
...t-metadata-editable-properties.constant.ts | 2 +
...metadata-and-related-flat-entities.util.ts | 60 +++
...ion-menu-item-record-identifier.service.ts | 5 +
...nimal-select-for-record-identifier.util.ts | 22 +-
.../dtos/update-object.input.ts | 2 +-
.../object-metadata.resolver.ts | 14 +-
.../types/object-metadata-overrides.type.ts | 1 +
...image-identifier-field-metadata-id.util.ts | 20 +
...eate-standard-flat-object-metadata.util.ts | 3 +-
packages/twenty-server/src/utils/image.ts | 19 +
...bject-image-identifier.integration-spec.ts | 139 +++++++
.../ImageIdentifierFieldMetadataTypes.ts | 6 +
packages/twenty-shared/src/constants/index.ts | 1 +
.../src/utils/image}/getLinkFaviconUrl.ts | 10 +-
.../twenty-shared/src/utils/image/index.ts | 1 +
packages/twenty-shared/src/utils/index.ts | 2 +
.../isImageIdentifierFieldMetadataType.ts | 7 +
58 files changed, 1986 insertions(+), 430 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/__tests__/getImageIdentifierFieldValue.test.ts
delete mode 100644 packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts
delete mode 100644 packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordImageIdentifierUpload.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/get-record-image-identifier.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-standard-object-image-identifier.integration-spec.ts
create mode 100644 packages/twenty-shared/src/constants/ImageIdentifierFieldMetadataTypes.ts
rename packages/{twenty-front/src/modules/navigation-menu-item/display/link/utils => twenty-shared/src/utils/image}/getLinkFaviconUrl.ts (56%)
create mode 100644 packages/twenty-shared/src/utils/validation/isImageIdentifierFieldMetadataType.ts
diff --git a/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx b/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx
index cd8d3ae7d6..19b8b9cf9d 100644
--- a/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx
+++ b/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx
@@ -1,9 +1,10 @@
import { type MouseEvent } from 'react';
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
-import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
+import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type MentionMenuListItemProps = {
recordId: string;
@@ -24,6 +25,12 @@ export const MentionMenuListItem = ({
isSelected,
onClick,
}: MentionMenuListItemProps) => {
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const objectMetadataItem = objectMetadataItems.find(
+ (item) => item.nameSingular === objectNameSingular,
+ );
+
const handleClick = (event?: MouseEvent) => {
event?.preventDefault();
event?.stopPropagation();
@@ -42,7 +49,7 @@ export const MentionMenuListItem = ({
placeholder={label}
placeholderColorSeed={recordId}
avatarUrl={getAbsoluteImageUrl(imageUrl)}
- type={getAvatarType(objectNameSingular) ?? 'rounded'}
+ type={getAvatarType(objectMetadataItem)}
size="sm"
/>
)}
diff --git a/packages/twenty-front/src/modules/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier.ts
index bf87e16192..34bdb44969 100644
--- a/packages/twenty-front/src/modules/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier.ts
+++ b/packages/twenty-front/src/modules/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier.ts
@@ -18,7 +18,7 @@ export const recordIdentifierToObjectRecordIdentifier = ({
recordIdentifier: RecordIdentifierDTO;
objectMetadataItem: EnrichedObjectMetadataItem;
}): ObjectRecordIdentifier => {
- const avatarType = getAvatarType(objectMetadataItem.nameSingular);
+ const avatarType = getAvatarType(objectMetadataItem);
const basePathToShowPage = getBasePathToShowPage({
objectNameSingular: objectMetadataItem.nameSingular,
diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay.tsx b/packages/twenty-front/src/modules/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay.tsx
index 39dcbb1793..674f5655a3 100644
--- a/packages/twenty-front/src/modules/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay.tsx
+++ b/packages/twenty-front/src/modules/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay.tsx
@@ -1,12 +1,11 @@
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
-import { isDefined } from 'twenty-shared/utils';
+import { getLinkFaviconUrl, isDefined } from 'twenty-shared/utils';
import { getIconTileColorShades } from 'twenty-ui/data-display';
import { type IconComponent } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
-import { getLinkFaviconUrl } from '@/navigation-menu-item/display/link/utils/getLinkFaviconUrl';
const failedFaviconUrls = new Set();
diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectRecordIdentifierByNameSingular.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectRecordIdentifierByNameSingular.test.tsx
index 41f413d16e..954d3f3c1b 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectRecordIdentifierByNameSingular.test.tsx
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectRecordIdentifierByNameSingular.test.tsx
@@ -65,7 +65,7 @@ describe('useGetObjectRecordIdentifierByNameSingular', () => {
rerender({
record: {
id: 'recordId',
- domainName: 'https://cool-company.com',
+ domainName: { primaryLinkUrl: 'https://cool-company.com' },
},
objectNameSingular: 'company',
});
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getImageIdentifierFieldValue.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getImageIdentifierFieldValue.test.ts
new file mode 100644
index 0000000000..3e6ad627e7
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getImageIdentifierFieldValue.test.ts
@@ -0,0 +1,90 @@
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { getImageIdentifierFieldValue } from '@/object-metadata/utils/getImageIdentifierFieldValue';
+import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { FieldMetadataType } from 'twenty-shared/types';
+
+const buildFieldMetadataItem = (
+ type: FieldMetadataType,
+ name = 'imageField',
+): FieldMetadataItem => ({ name, type }) as FieldMetadataItem;
+
+const buildRecord = (value: unknown, name = 'imageField'): ObjectRecord =>
+ ({ id: 'record-id', [name]: value }) as unknown as ObjectRecord;
+
+describe('getImageIdentifierFieldValue', () => {
+ it('returns null when the image identifier field metadata item is undefined', () => {
+ expect(getImageIdentifierFieldValue(buildRecord('value'), undefined)).toBe(
+ null,
+ );
+ });
+
+ it('returns null when the field value is not defined on the record', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord(undefined),
+ buildFieldMetadataItem(FieldMetadataType.FILES),
+ ),
+ ).toBe(null);
+ });
+
+ describe('FILES', () => {
+ it('returns the url of the first file', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord([{ url: 'https://example.com/a.png' }]),
+ buildFieldMetadataItem(FieldMetadataType.FILES),
+ ),
+ ).toBe('https://example.com/a.png');
+ });
+
+ it('returns null for an empty files array', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord([]),
+ buildFieldMetadataItem(FieldMetadataType.FILES),
+ ),
+ ).toBe(null);
+ });
+ });
+
+ describe('LINKS', () => {
+ it('returns the favicon url when requests to twenty-icons are allowed', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord({ primaryLinkUrl: 'twenty.com' }),
+ buildFieldMetadataItem(FieldMetadataType.LINKS),
+ true,
+ ),
+ ).toBe('https://twenty-icons.com/twenty.com');
+ });
+
+ it('returns null when requests to twenty-icons are not allowed', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord({ primaryLinkUrl: 'twenty.com' }),
+ buildFieldMetadataItem(FieldMetadataType.LINKS),
+ false,
+ ),
+ ).toBe(null);
+ });
+
+ it('returns null when the primary link url is not defined', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord({ primaryLinkUrl: null }),
+ buildFieldMetadataItem(FieldMetadataType.LINKS),
+ true,
+ ),
+ ).toBe(null);
+ });
+ });
+
+ it('returns null for unsupported field types (image identifiers are constrained to FILES/LINKS)', () => {
+ expect(
+ getImageIdentifierFieldValue(
+ buildRecord('https://example.com/avatar.png'),
+ buildFieldMetadataItem(FieldMetadataType.TEXT),
+ ),
+ ).toBe(null);
+ });
+});
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts
index f047d43a03..11919c25b7 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts
@@ -1,17 +1,39 @@
-import { CoreObjectNameSingular } from 'twenty-shared/types';
+import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
+import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
+import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
-export const getAvatarType = (objectNameSingular: string) => {
- if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) {
+export const getAvatarType = (
+ objectMetadataItem?: Pick<
+ EnrichedObjectMetadataItem,
+ 'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
+ >,
+) => {
+ if (!isDefined(objectMetadataItem)) {
return 'rounded';
}
- if (objectNameSingular === CoreObjectNameSingular.Company) {
- return 'squared';
+ if (
+ objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember
+ ) {
+ return 'rounded';
+ }
+
+ const imageIdentifierFieldMetadataItem =
+ getImageIdentifierFieldMetadataItem(objectMetadataItem);
+
+ if (isDefined(imageIdentifierFieldMetadataItem)) {
+ switch (imageIdentifierFieldMetadataItem.type) {
+ case FieldMetadataType.LINKS:
+ return 'squared';
+ case FieldMetadataType.FILES:
+ return 'rounded';
+ }
}
if (
- objectNameSingular === CoreObjectNameSingular.Task ||
- objectNameSingular === CoreObjectNameSingular.Note
+ objectMetadataItem.nameSingular === CoreObjectNameSingular.Task ||
+ objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
) {
return 'icon';
}
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts
index 65de69ac71..c2de8ff853 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts
@@ -1,9 +1,9 @@
-import { type Company } from '@/companies/types/Company';
import { CoreObjectNameSingular } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
-import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
-import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
+
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
export const getAvatarUrl = (
@@ -16,22 +16,10 @@ export const getAvatarUrl = (
return record.avatarUrl ?? undefined;
}
- if (
- objectNameSingular === CoreObjectNameSingular.Company &&
- allowRequestsToTwentyIcons === true
- ) {
- return getLogoUrlFromDomainName(
- getCompanyDomainName(record as Company) ?? '',
- );
- }
-
- if (objectNameSingular === CoreObjectNameSingular.Person) {
- return record.avatarFile?.[0]?.url ?? '';
- }
-
const imageIdentifierFieldValue = getImageIdentifierFieldValue(
record,
imageIdentifierFieldMetadataItem,
+ allowRequestsToTwentyIcons,
);
if (isDefined(imageIdentifierFieldValue)) {
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts
index 44c4c52cdf..e1ac0eeb99 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts
@@ -1,16 +1,14 @@
-import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
-import { isImageIdentifierField } from '@/object-metadata/utils/isImageIdentifierField';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export const getImageIdentifierFieldMetadataItem = (
objectMetadataItem: Pick<
EnrichedObjectMetadataItem,
- 'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
+ 'fields' | 'imageIdentifierFieldMetadataId'
>,
): FieldMetadataItem | undefined =>
- objectMetadataItem.fields.find((fieldMetadataItem) =>
- isImageIdentifierField({
- fieldMetadataItem,
- objectMetadataItem,
- }),
+ objectMetadataItem.fields.find(
+ (fieldMetadataItem) =>
+ fieldMetadataItem.id ===
+ objectMetadataItem.imageIdentifierFieldMetadataId,
);
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts
index e81e0fa65d..480eb14dd0 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts
@@ -1,14 +1,46 @@
+import { isNonEmptyString } from '@sniptt/guards';
+
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
-import { isDefined } from 'twenty-shared/utils';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { getLinkFaviconUrl, isDefined } from 'twenty-shared/utils';
export const getImageIdentifierFieldValue = (
record: ObjectRecord,
imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
-) => {
- if (isDefined(imageIdentifierFieldMetadataItem?.name)) {
- return record[imageIdentifierFieldMetadataItem.name] as string;
+ allowRequestsToTwentyIcons?: boolean,
+): string | null => {
+ if (!isDefined(imageIdentifierFieldMetadataItem?.name)) {
+ return null;
}
- return null;
+ const fieldValue = record[imageIdentifierFieldMetadataItem.name];
+
+ if (!isDefined(fieldValue)) {
+ return null;
+ }
+
+ switch (imageIdentifierFieldMetadataItem.type) {
+ case FieldMetadataType.FILES: {
+ const url = Array.isArray(fieldValue) ? fieldValue[0]?.url : undefined;
+
+ return isNonEmptyString(url) ? url : null;
+ }
+ case FieldMetadataType.LINKS: {
+ if (allowRequestsToTwentyIcons !== true) {
+ return null;
+ }
+
+ const primaryLinkUrl =
+ typeof fieldValue === 'object' && 'primaryLinkUrl' in fieldValue
+ ? fieldValue.primaryLinkUrl
+ : undefined;
+
+ return isNonEmptyString(primaryLinkUrl)
+ ? (getLinkFaviconUrl(primaryLinkUrl) ?? null)
+ : null;
+ }
+ default:
+ return null;
+ }
};
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts
index 7a9cb10e60..a30d3d61eb 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts
@@ -1,4 +1,5 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
+import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
@@ -31,13 +32,11 @@ export const getObjectRecordIdentifier = ({
labelIdentifierFieldMetadataItem,
);
- const imageIdentifierFieldMetadata = objectMetadataItem.fields.find(
- (field) => field.id === objectMetadataItem.imageIdentifierFieldMetadataId,
- );
+ const imageIdentifierFieldMetadata =
+ getImageIdentifierFieldMetadataItem(objectMetadataItem);
- const avatarType = getAvatarType(objectMetadataItem.nameSingular);
+ const avatarType = getAvatarType(objectMetadataItem);
- // TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database
const avatarUrl = getAvatarUrl(
objectMetadataItem.nameSingular,
record,
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts b/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts
deleted file mode 100644
index 614fc450fe..0000000000
--- a/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { CoreObjectNameSingular } from 'twenty-shared/types';
-import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
-import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
-
-export const isImageIdentifierField = ({
- fieldMetadataItem,
- objectMetadataItem,
-}: {
- fieldMetadataItem: Pick;
- objectMetadataItem: Pick<
- EnrichedObjectMetadataItem,
- 'imageIdentifierFieldMetadataId' | 'nameSingular'
- >;
-}) => {
- if (
- objectMetadataItem.nameSingular === CoreObjectNameSingular.Company &&
- fieldMetadataItem.name === 'domainName'
- ) {
- return true;
- }
-
- if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) {
- return fieldMetadataItem.name === 'avatarFile';
- }
-
- return (
- fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId
- );
-};
diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap
index ca5df976e3..afb3b75463 100644
--- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap
+++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap
@@ -3,6 +3,7 @@
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
"accountOwner": {
+ "avatarUrl": true,
"id": true,
"name": true,
},
diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent.tsx
index 1c83c3635e..60371539d9 100644
--- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent.tsx
@@ -88,7 +88,7 @@ export const MultipleRecordPickerMenuItemContent = ({
placeholderColorSeed={searchRecord.recordId}
placeholder={displayText}
size="md"
- type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
+ type={getAvatarType(objectMetadataItem)}
/>
}
text={displayText}
diff --git a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem.tsx
index 0b77baa919..21db14e8f4 100644
--- a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem.tsx
@@ -1,3 +1,6 @@
+import { useMemo } from 'react';
+
+import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
@@ -7,13 +10,13 @@ import { type RecordPickerPickableMorphItem } from '@/object-record/record-picke
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
-import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
-import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
-import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
+import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
+import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
+import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type SingleRecordPickerMenuItemProps = {
morphItem: RecordPickerPickableMorphItem;
@@ -51,6 +54,18 @@ export const SingleRecordPickerMenuItem = ({
recordPickerComponentInstanceId,
);
+ const objectMetadataItem = useMemo(
+ () =>
+ singleRecordPickerSearchableObjectMetadataItems.find(
+ (searchableObjectMetadataItem: EnrichedObjectMetadataItem) =>
+ searchableObjectMetadataItem.id === morphItem.objectMetadataId,
+ ),
+ [
+ singleRecordPickerSearchableObjectMetadataItems,
+ morphItem.objectMetadataId,
+ ],
+ );
+
if (!isDefined(searchRecordStore)) {
return null;
}
@@ -78,9 +93,7 @@ export const SingleRecordPickerMenuItem = ({
placeholderColorSeed={morphItem.recordId}
placeholder={searchRecordStore.label}
size="md"
- type={
- getAvatarType(searchRecordStore.objectNameSingular) ?? 'rounded'
- }
+ type={getAvatarType(objectMetadataItem)}
/>
}
contextualText={
diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx
index 0910c452a3..87dd88b1a7 100644
--- a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx
@@ -1,10 +1,9 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
-import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
-import { usePersonAvatarUpload } from '@/object-record/record-show/hooks/usePersonAvatarUpload';
+import { useRecordImageIdentifierUpload } from '@/object-record/record-show/hooks/useRecordImageIdentifierUpload';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@@ -49,7 +48,10 @@ export const SummaryCard = ({
objectNameSingular,
});
- const { onUploadPicture } = usePersonAvatarUpload(objectRecordId);
+ const { onUploadPicture } = useRecordImageIdentifierUpload({
+ objectNameSingular,
+ recordId: objectRecordId,
+ });
const isMobile = useIsMobile() || isInSidePanel;
@@ -115,11 +117,7 @@ export const SummaryCard = ({
}
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
- onUploadPicture={
- objectNameSingular === CoreObjectNameSingular.Person
- ? onUploadPicture
- : undefined
- }
+ onUploadPicture={onUploadPicture}
/>
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts
deleted file mode 100644
index 5b21dabf84..0000000000
--- a/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
-import { CoreObjectNameSingular } from 'twenty-shared/types';
-import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
-import { useApolloClient, useMutation } from '@apollo/client/react';
-import { t } from '@lingui/core/macro';
-import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
-import {
- FieldMetadataType,
- UploadFilesFieldFileDocument,
-} from '~/generated-metadata/graphql';
-
-export const usePersonAvatarUpload = (personRecordId: string) => {
- const apolloClient = useApolloClient();
- const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
- client: apolloClient,
- });
- const { updateOneRecord } = useUpdateOneRecord();
-
- const { objectMetadataItem: personMetadata } = useObjectMetadataItem({
- objectNameSingular: CoreObjectNameSingular.Person,
- });
-
- const avatarFileFieldMetadataId = personMetadata.fields.find(
- (field) =>
- field.type === FieldMetadataType.FILES && field.name === 'avatarFile',
- )?.id;
-
- const onUploadPicture = async (file: File) => {
- assertIsDefinedOrThrow(
- avatarFileFieldMetadataId,
- new Error(t`Avatar file field not found for person object`),
- );
-
- const result = await uploadFilesFieldFile({
- variables: { file, fieldMetadataId: avatarFileFieldMetadataId },
- });
-
- const uploadedFile = result?.data?.uploadFilesFieldFile;
-
- if (!isDefined(uploadedFile)) {
- return;
- }
-
- await updateOneRecord({
- objectNameSingular: CoreObjectNameSingular.Person,
- idToUpdate: personRecordId,
- updateOneRecordInput: {
- avatarFile: [
- {
- fileId: uploadedFile.id,
- label: file.name,
- },
- ],
- },
- });
- };
-
- return { onUploadPicture };
-};
diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordImageIdentifierUpload.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordImageIdentifierUpload.ts
new file mode 100644
index 0000000000..468c0185a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordImageIdentifierUpload.ts
@@ -0,0 +1,82 @@
+import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
+import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
+import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
+import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
+import { useApolloClient, useMutation } from '@apollo/client/react';
+import { isDefined } from 'twenty-shared/utils';
+import {
+ FieldMetadataType,
+ UploadFilesFieldFileDocument,
+} from '~/generated-metadata/graphql';
+
+type UseRecordImageIdentifierUploadParams = {
+ objectNameSingular: string;
+ recordId: string;
+};
+
+export const useRecordImageIdentifierUpload = ({
+ objectNameSingular,
+ recordId,
+}: UseRecordImageIdentifierUploadParams) => {
+ const apolloClient = useApolloClient();
+ const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
+ client: apolloClient,
+ });
+ const { updateOneRecord } = useUpdateOneRecord();
+
+ const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
+
+ const imageIdentifierFieldMetadataItem =
+ getImageIdentifierFieldMetadataItem(objectMetadataItem);
+
+ const filesImageIdentifierFieldMetadataItem =
+ imageIdentifierFieldMetadataItem?.type === FieldMetadataType.FILES
+ ? imageIdentifierFieldMetadataItem
+ : undefined;
+
+ const isImageIdentifierFieldReadOnly = useIsRecordFieldReadOnly({
+ recordId,
+ objectMetadataId: objectMetadataItem.id,
+ fieldMetadataId: filesImageIdentifierFieldMetadataItem?.id ?? '',
+ });
+
+ const canUploadImageIdentifier =
+ isDefined(filesImageIdentifierFieldMetadataItem) &&
+ !isImageIdentifierFieldReadOnly;
+
+ const onUploadPicture = async (file: File) => {
+ if (!isDefined(filesImageIdentifierFieldMetadataItem)) {
+ return;
+ }
+
+ const result = await uploadFilesFieldFile({
+ variables: {
+ file,
+ fieldMetadataId: filesImageIdentifierFieldMetadataItem.id,
+ },
+ });
+
+ const uploadedFile = result?.data?.uploadFilesFieldFile;
+
+ if (!isDefined(uploadedFile)) {
+ return;
+ }
+
+ await updateOneRecord({
+ objectNameSingular,
+ idToUpdate: recordId,
+ updateOneRecordInput: {
+ [filesImageIdentifierFieldMetadataItem.name]: [
+ {
+ fileId: uploadedFile.id,
+ label: file.name,
+ },
+ ],
+ },
+ });
+ };
+
+ return {
+ onUploadPicture: canUploadImageIdentifier ? onUploadPicture : undefined,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts
index 6acad71124..4e34a9a96c 100644
--- a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts
+++ b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts
@@ -5,6 +5,7 @@ import {
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
+import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
@@ -72,13 +73,9 @@ export const getRecordChipGenerators = (
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
const imageIdentifierFieldMetadataToUse =
- objectMetadataItemToUse.fields.find(
- (field) =>
- field.id ===
- objectMetadataItemToUse.imageIdentifierFieldMetadataId,
- );
+ getImageIdentifierFieldMetadataItem(objectMetadataItemToUse);
- const avatarType = getAvatarType(objectNameSingularToFind);
+ const avatarType = getAvatarType(objectMetadataItemToUse);
return [
fieldMetadataItem.name,
diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx
index d2e9fd8b6f..01ed9ecd57 100644
--- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx
@@ -2,6 +2,7 @@ import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { useGetIsMetadataItemCustom } from '@/object-metadata/hooks/useGetIsMetadataItemCustom';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
@@ -10,10 +11,10 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { zodResolver } from '@hookform/resolvers/zod';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
-import { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import {
+ isImageIdentifierFieldMetadataType,
isLabelIdentifierFieldMetadataTypes,
isSearchableFieldType,
} from 'twenty-shared/utils';
@@ -67,9 +68,18 @@ export const SettingsDataModelObjectIdentifiersForm = ({
const handleSave = async (
formValues: SettingsDataModelObjectIdentifiersFormValues,
) => {
+ const {
+ labelIdentifierFieldMetadataId: _labelIdentifierFieldMetadataId,
+ ...payloadWithoutLabelIdentifier
+ } = formValues;
+
+ const updatePayload = isCustomObject
+ ? formValues
+ : payloadWithoutLabelIdentifier;
+
const result = await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
- updatePayload: formValues,
+ updatePayload,
});
if (result.status === 'successful') {
@@ -78,23 +88,25 @@ export const SettingsDataModelObjectIdentifiersForm = ({
};
const { getIcon } = useIcons();
- const labelIdentifierFieldOptions = useMemo(
- () =>
- getActiveFieldMetadataItems(objectMetadataItem)
- .filter(
- ({ id, type }) =>
- (isLabelIdentifierFieldMetadataTypes(type) &&
- isSearchableFieldType(type)) ||
- objectMetadataItem.labelIdentifierFieldMetadataId === id,
- )
- .map>((fieldMetadataItem) => ({
- Icon: getIcon(fieldMetadataItem.icon),
- label: fieldMetadataItem.label,
- value: fieldMetadataItem.id,
- })),
- [getIcon, objectMetadataItem],
- );
- const imageIdentifierFieldOptions: SelectOption[] = [];
+
+ const mapFieldToSelectOption = (
+ fieldMetadataItem: FieldMetadataItem,
+ ): SelectOption => ({
+ Icon: getIcon(fieldMetadataItem.icon),
+ label: fieldMetadataItem.label,
+ value: fieldMetadataItem.id,
+ });
+
+ const labelIdentifierFieldOptions = getActiveFieldMetadataItems(
+ objectMetadataItem,
+ )
+ .filter(
+ ({ id, type }) =>
+ (isLabelIdentifierFieldMetadataTypes(type) &&
+ isSearchableFieldType(type)) ||
+ objectMetadataItem.labelIdentifierFieldMetadataId === id,
+ )
+ .map(mapFieldToSelectOption);
const emptyOption: SelectOption = {
Icon: IconCircleOff,
@@ -102,6 +114,17 @@ export const SettingsDataModelObjectIdentifiersForm = ({
value: null,
};
+ const imageIdentifierFieldOptions = [
+ emptyOption,
+ ...getActiveFieldMetadataItems(objectMetadataItem)
+ .filter(
+ ({ id, type }) =>
+ isImageIdentifierFieldMetadataType(type) ||
+ objectMetadataItem.imageIdentifierFieldMetadataId === id,
+ )
+ .map(mapFieldToSelectOption),
+ ];
+
const navigate = useNavigate();
return (
@@ -112,14 +135,16 @@ export const SettingsDataModelObjectIdentifiersForm = ({
fieldName: LABEL_IDENTIFIER_FIELD_METADATA_ID,
options: labelIdentifierFieldOptions,
defaultValue: objectMetadataItem.labelIdentifierFieldMetadataId,
+ disabled: !isCustomObject || readonly,
},
{
label: t`Record image`,
fieldName: IMAGE_IDENTIFIER_FIELD_METADATA_ID,
options: imageIdentifierFieldOptions,
- defaultValue: null,
+ defaultValue: objectMetadataItem.imageIdentifierFieldMetadataId,
+ disabled: readonly,
},
- ].map(({ fieldName, label, options, defaultValue }) => (
+ ].map(({ fieldName, label, options, defaultValue, disabled }) => (
{
- onChange(value);
+ onChange={(newValue) => {
+ onChange(newValue);
formConfig.handleSubmit(handleSave)();
}}
/>
diff --git a/packages/twenty-front/src/testing/mock-data/generated/data/companies/mock-companies-data.ts b/packages/twenty-front/src/testing/mock-data/generated/data/companies/mock-companies-data.ts
index 5667acdf32..cefe93fc08 100644
--- a/packages/twenty-front/src/testing/mock-data/generated/data/companies/mock-companies-data.ts
+++ b/packages/twenty-front/src/testing/mock-data/generated/data/companies/mock-companies-data.ts
@@ -11,6 +11,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -102,7 +103,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-22.png",
+ "avatarFile": null,
"id": "20202020-b030-4743-9edd-d1a1776d653d",
"name": {
"__typename": "FullName",
@@ -115,7 +116,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-33.png",
+ "avatarFile": null,
"id": "20202020-bcc1-434e-995a-f80dfa92b596",
"name": {
"__typename": "FullName",
@@ -215,6 +216,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -306,7 +308,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "",
+ "avatarFile": null,
"id": "20202020-bf28-4a93-bba3-b02aa55543a3",
"name": {
"__typename": "FullName",
@@ -319,7 +321,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-49.png",
+ "avatarFile": null,
"id": "20202020-b0ae-46da-9697-ed949ee75b67",
"name": {
"__typename": "FullName",
@@ -411,6 +413,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-77d5-4cb6-b60a-f4a835a85d61",
"name": {
"__typename": "FullName",
@@ -502,7 +505,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-86.png",
+ "avatarFile": null,
"id": "20202020-b165-49bf-b2c1-60fd1ee3d368",
"name": {
"__typename": "FullName",
@@ -515,7 +518,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-76.png",
+ "avatarFile": null,
"id": "20202020-b664-4460-83e8-5c5f1c64c836",
"name": {
"__typename": "FullName",
@@ -599,6 +602,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -707,7 +711,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-84.png",
+ "avatarFile": null,
"id": "20202020-bfea-4a51-81be-d46de6a93db7",
"name": {
"__typename": "FullName",
@@ -720,7 +724,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-33.png",
+ "avatarFile": null,
"id": "20202020-bfa7-4546-a865-18a9cd06de4c",
"name": {
"__typename": "FullName",
@@ -812,6 +816,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -912,7 +917,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-71.png",
+ "avatarFile": null,
"id": "20202020-b397-497b-90bc-f62c1c34b2a3",
"name": {
"__typename": "FullName",
@@ -925,7 +930,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-58.png",
+ "avatarFile": null,
"id": "20202020-beb3-40b1-8f18-de25a7fd1146",
"name": {
"__typename": "FullName",
@@ -1033,6 +1038,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-77d5-4cb6-b60a-f4a835a85d61",
"name": {
"__typename": "FullName",
@@ -1124,7 +1130,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-1.png",
+ "avatarFile": null,
"id": "20202020-b171-46bc-a285-ec8ee3e3b702",
"name": {
"__typename": "FullName",
@@ -1137,7 +1143,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-7.png",
+ "avatarFile": null,
"id": "20202020-b307-4b8e-9281-b3ddb0ef420c",
"name": {
"__typename": "FullName",
@@ -1229,6 +1235,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -1320,7 +1327,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "",
+ "avatarFile": null,
"id": "20202020-b29f-45a3-9e75-6a1d49f1299f",
"name": {
"__typename": "FullName",
@@ -1333,7 +1340,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-1.png",
+ "avatarFile": null,
"id": "20202020-bcbc-4e67-8f84-df60aad8cd7f",
"name": {
"__typename": "FullName",
@@ -1441,6 +1448,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -1532,7 +1540,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-19.png",
+ "avatarFile": null,
"id": "20202020-bab2-4892-8834-6ca25212fd35",
"name": {
"__typename": "FullName",
@@ -1545,7 +1553,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "",
+ "avatarFile": null,
"id": "20202020-b48a-4ca5-9596-abdee52b69a6",
"name": {
"__typename": "FullName",
@@ -1653,6 +1661,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -1744,7 +1753,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-87.png",
+ "avatarFile": null,
"id": "20202020-b597-4d76-b0d5-cd2b7d7e0255",
"name": {
"__typename": "FullName",
@@ -1757,7 +1766,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "",
+ "avatarFile": null,
"id": "20202020-bbaf-436a-b635-79e7c349f388",
"name": {
"__typename": "FullName",
@@ -1889,6 +1898,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "Company",
"accountOwner": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-77d5-4cb6-b60a-f4a835a85d61",
"name": {
"__typename": "FullName",
@@ -1997,7 +2007,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "",
+ "avatarFile": null,
"id": "20202020-ba0b-48a9-85ba-e223975696ea",
"name": {
"__typename": "FullName",
@@ -2010,7 +2020,7 @@ export const mockedCompanyRecords: ObjectRecord[] =
"__typename": "PersonEdge",
"node": {
"__typename": "Person",
- "avatarUrl": "https://twentyhq.github.io/placeholder-images/people/image-65.png",
+ "avatarFile": null,
"id": "20202020-b3e6-4514-88e8-7394fa3017cc",
"name": {
"__typename": "FullName",
diff --git a/packages/twenty-front/src/testing/mock-data/generated/data/tasks/mock-tasks-data.ts b/packages/twenty-front/src/testing/mock-data/generated/data/tasks/mock-tasks-data.ts
index 7aff064bb2..c39f57d840 100644
--- a/packages/twenty-front/src/testing/mock-data/generated/data/tasks/mock-tasks-data.ts
+++ b/packages/twenty-front/src/testing/mock-data/generated/data/tasks/mock-tasks-data.ts
@@ -11,6 +11,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -102,6 +103,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -193,6 +195,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -284,6 +287,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -375,6 +379,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -466,6 +471,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -557,6 +563,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -648,6 +655,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -739,6 +747,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-1553-45c6-a028-5a9064cce07f",
"name": {
"__typename": "FullName",
@@ -830,6 +839,7 @@ export const mockedTaskRecords: ObjectRecord[] =
"__typename": "Task",
"assignee": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-77d5-4cb6-b60a-f4a835a85d61",
"name": {
"__typename": "FullName",
diff --git a/packages/twenty-front/src/testing/mock-data/generated/data/timelineActivities/mock-timelineActivities-data.ts b/packages/twenty-front/src/testing/mock-data/generated/data/timelineActivities/mock-timelineActivities-data.ts
index 2fa9d07456..806c0e9d67 100644
--- a/packages/twenty-front/src/testing/mock-data/generated/data/timelineActivities/mock-timelineActivities-data.ts
+++ b/packages/twenty-front/src/testing/mock-data/generated/data/timelineActivities/mock-timelineActivities-data.ts
@@ -36,6 +36,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "goo.gle",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a305-41e7-8c72-ba44072a4c58",
"name": "Google"
},
@@ -76,6 +82,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -114,6 +121,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "microsoft.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a225-4b3d-a89c-7f6c30df998a",
"name": "Microsoft"
},
@@ -154,6 +167,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -192,6 +206,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "metacareers.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a8b0-422c-8fcf-5b7496f94975",
"name": "Meta"
},
@@ -232,6 +252,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -270,6 +291,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "slb.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-aaf7-41d6-87a9-7add07bebfd8",
"name": "SLB"
},
@@ -310,6 +337,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -348,6 +376,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "cisco.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a19d-422b-9cb2-5f8382a56877",
"name": "Cisco"
},
@@ -388,6 +422,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -426,6 +461,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "uber.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a39c-4644-867d-e8e1851b3ee8",
"name": "Uber"
},
@@ -466,6 +507,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -504,6 +546,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "salesforce.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a0eb-4c51-aa03-c4cd2423d7cb",
"name": "Salesforce"
},
@@ -544,6 +592,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -582,6 +631,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "amdocs.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a9b5-48ec-97c0-dbbfcbe8df1b",
"name": "Amdocs"
},
@@ -622,6 +677,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -660,6 +716,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "vmware.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a89d-44f9-ac9c-25e462460cb0",
"name": "VMware"
},
@@ -700,6 +762,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
@@ -738,6 +801,12 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"targetCompany": {
"__typename": "Company",
+ "domainName": {
+ "__typename": "Links",
+ "primaryLinkUrl": "globallogic.com",
+ "primaryLinkLabel": "",
+ "secondaryLinks": []
+ },
"id": "20202020-a377-4693-a2d9-89dc9188a1dc",
"name": "GlobalLogic"
},
@@ -778,6 +847,7 @@ export const mockedTimelineActivityRecords: ObjectRecord[] =
},
"workspaceMember": {
"__typename": "WorkspaceMember",
+ "avatarUrl": "",
"id": "20202020-0687-4c41-b707-ed1bfca972a7",
"name": {
"__typename": "FullName",
diff --git a/packages/twenty-front/src/testing/mock-data/generated/metadata/objects/mock-objects-metadata.ts b/packages/twenty-front/src/testing/mock-data/generated/metadata/objects/mock-objects-metadata.ts
index d079a68345..b738d23c46 100644
--- a/packages/twenty-front/src/testing/mock-data/generated/metadata/objects/mock-objects-metadata.ts
+++ b/packages/twenty-front/src/testing/mock-data/generated/metadata/objects/mock-objects-metadata.ts
@@ -10243,7 +10243,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"createdAt": "2026-04-10T08:55:56.200Z",
"updatedAt": "2026-04-10T08:55:56.200Z",
"labelIdentifierFieldMetadataId": "d8ff93be-5b55-4e4b-af50-20eb2f38320c",
- "imageIdentifierFieldMetadataId": null,
+ "imageIdentifierFieldMetadataId": "07ce14cd-f2e2-4f21-ad62-52f0a1329a6b",
"applicationId": "dd6a5463-023d-4a10-855f-a4abaf32c1ec",
"shortcut": null,
"isLabelSyncedWithName": false,
@@ -14806,7 +14806,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"createdAt": "2026-04-10T08:55:56.200Z",
"updatedAt": "2026-04-10T08:55:56.200Z",
"labelIdentifierFieldMetadataId": "1b86fb74-25cf-4228-af97-b6a479643407",
- "imageIdentifierFieldMetadataId": null,
+ "imageIdentifierFieldMetadataId": "a1bbfc74-315b-475c-b13b-4268476c2894",
"applicationId": "dd6a5463-023d-4a10-855f-a4abaf32c1ec",
"shortcut": "P",
"isLabelSyncedWithName": false,
@@ -16669,7 +16669,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"createdAt": "2026-04-10T08:55:56.200Z",
"updatedAt": "2026-04-10T08:55:56.200Z",
"labelIdentifierFieldMetadataId": "50c85704-9c8e-4e30-8a86-dc6adb756999",
- "imageIdentifierFieldMetadataId": null,
+ "imageIdentifierFieldMetadataId": "be9fd5e1-ca36-4b91-ba14-22a5ab409e92",
"applicationId": "dd6a5463-023d-4a10-855f-a4abaf32c1ec",
"shortcut": "C",
"isLabelSyncedWithName": false,
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module.ts
new file mode 100644
index 0000000000..00f17f3f42
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module.ts
@@ -0,0 +1,26 @@
+import { Module } from '@nestjs/common';
+
+import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
+import { BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command';
+import { MigratePersonAvatarUrlToAvatarFileCommand } from 'src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command';
+import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
+import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module';
+import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.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: [
+ ApplicationModule,
+ FilesFieldModule,
+ SecureHttpClientModule,
+ WorkspaceCacheModule,
+ WorkspaceMigrationModule,
+ WorkspaceIteratorModule,
+ ],
+ providers: [
+ BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand,
+ MigratePersonAvatarUrlToAvatarFileCommand,
+ ],
+})
+export class V2_22_UpgradeVersionCommandModule {}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command.ts
new file mode 100644
index 0000000000..f67e291738
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command.ts
@@ -0,0 +1,175 @@
+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 IMAGE_IDENTIFIER_BACKFILL_TARGETS = [
+ {
+ objectNameForLog: 'company',
+ objectUniversalIdentifier: STANDARD_OBJECTS.company.universalIdentifier,
+ fieldNameForLog: 'domainName',
+ fieldUniversalIdentifier:
+ STANDARD_OBJECTS.company.fields.domainName.universalIdentifier,
+ deprecatedFieldUniversalIdentifier: undefined,
+ },
+ {
+ objectNameForLog: 'person',
+ objectUniversalIdentifier: STANDARD_OBJECTS.person.universalIdentifier,
+ fieldNameForLog: 'avatarFile',
+ fieldUniversalIdentifier:
+ STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier,
+ deprecatedFieldUniversalIdentifier:
+ STANDARD_OBJECTS.person.fields.avatarUrl.universalIdentifier,
+ },
+] as const;
+
+@RegisteredWorkspaceCommand('2.22.0', 1783959648000)
+@Command({
+ name: 'upgrade:2-22:backfill-company-person-image-identifier-field-metadata-id',
+ description:
+ 'Backfill imageIdentifierFieldMetadataId on company (domainName) and person (avatarFile) for existing workspaces.',
+})
+export class BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand 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 {
+ const isDryRun = options.dryRun ?? false;
+
+ const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatObjectMetadataMaps',
+ 'flatFieldMetadataMaps',
+ ]);
+
+ const flatObjectMetadataToUpdate: FlatObjectMetadata[] = [];
+
+ for (const target of IMAGE_IDENTIFIER_BACKFILL_TARGETS) {
+ const existingObject =
+ findFlatEntityByUniversalIdentifier({
+ flatEntityMaps: flatObjectMetadataMaps,
+ universalIdentifier: target.objectUniversalIdentifier,
+ });
+
+ if (!isDefined(existingObject)) {
+ this.logger.log(
+ `${target.objectNameForLog} object not found for workspace ${workspaceId}, skipping`,
+ );
+
+ continue;
+ }
+
+ const currentIdentifier =
+ existingObject.imageIdentifierFieldMetadataUniversalIdentifier;
+
+ // Backfill when unset, or when it still points to the deprecated field
+ // (e.g. person.avatarUrl). Never clobber a real user customization.
+ const isBackfillable =
+ !isDefined(currentIdentifier) ||
+ currentIdentifier === target.deprecatedFieldUniversalIdentifier;
+
+ if (!isBackfillable) {
+ this.logger.log(
+ `imageIdentifierFieldMetadataId already set on ${target.objectNameForLog} for workspace ${workspaceId}, skipping`,
+ );
+
+ continue;
+ }
+
+ const existingField =
+ findFlatEntityByUniversalIdentifier({
+ flatEntityMaps: flatFieldMetadataMaps,
+ universalIdentifier: target.fieldUniversalIdentifier,
+ });
+
+ if (!isDefined(existingField)) {
+ this.logger.log(
+ `${target.fieldNameForLog} field not found on ${target.objectNameForLog} for workspace ${workspaceId}, skipping`,
+ );
+
+ continue;
+ }
+
+ flatObjectMetadataToUpdate.push({
+ ...existingObject,
+ imageIdentifierFieldMetadataUniversalIdentifier:
+ target.fieldUniversalIdentifier,
+ });
+ }
+
+ if (flatObjectMetadataToUpdate.length === 0) {
+ this.logger.log(
+ `Nothing to backfill for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ if (isDryRun) {
+ this.logger.log(
+ `[DRY RUN] Would backfill imageIdentifierFieldMetadataId on ${flatObjectMetadataToUpdate
+ .map((flatObjectMetadata) => flatObjectMetadata.nameSingular)
+ .join(', ')} for workspace ${workspaceId}`,
+ );
+
+ return;
+ }
+
+ const { twentyStandardFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ { workspaceId },
+ );
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
+ {
+ allFlatEntityOperationByMetadataName: {
+ objectMetadata: {
+ flatEntityToCreate: [],
+ flatEntityToDelete: [],
+ flatEntityToUpdate: flatObjectMetadataToUpdate,
+ },
+ },
+ 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 ${flatObjectMetadataToUpdate
+ .map((flatObjectMetadata) => flatObjectMetadata.nameSingular)
+ .join(', ')} for workspace ${workspaceId}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command.ts
new file mode 100644
index 0000000000..4bc2de4e24
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command.ts
@@ -0,0 +1,350 @@
+import { Command } from 'nest-commander';
+import { isNonEmptyString } from '@sniptt/guards';
+import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
+import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
+import { IsNull, MoreThan, Not } from 'typeorm';
+
+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 { type FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
+import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
+import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.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 { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
+import { fetchImageWithTypeFromUrl } from 'src/utils/image';
+import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
+
+const PERSON_BATCH_SIZE = 100;
+const MIN_UUID = '00000000-0000-0000-0000-000000000000';
+
+@RegisteredWorkspaceCommand('2.22.0', 1783960128000)
+@Command({
+ name: 'upgrade:2-22:migrate-person-avatar-url-to-avatar-file',
+ description:
+ 'Migrate legacy person.avatarUrl (external image URL) into the avatarFile FILES field for existing workspaces.',
+})
+export class MigratePersonAvatarUrlToAvatarFileCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
+ constructor(
+ protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ private readonly workspaceCacheService: WorkspaceCacheService,
+ private readonly filesFieldService: FilesFieldService,
+ private readonly secureHttpClientService: SecureHttpClientService,
+ ) {
+ super(workspaceIteratorService);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ dataSource,
+ }: RunOnWorkspaceArgs): Promise {
+ const isDryRun = options.dryRun ?? false;
+
+ if (!isDefined(dataSource)) {
+ this.logger.log(
+ `No workspace data source for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatObjectMetadataMaps',
+ 'flatFieldMetadataMaps',
+ ]);
+
+ const personObject =
+ findFlatEntityByUniversalIdentifier({
+ flatEntityMaps: flatObjectMetadataMaps,
+ universalIdentifier: STANDARD_OBJECTS.person.universalIdentifier,
+ });
+
+ if (!isDefined(personObject)) {
+ this.logger.log(
+ `person object not found for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ const avatarFileField =
+ findFlatEntityByUniversalIdentifier({
+ flatEntityMaps: flatFieldMetadataMaps,
+ universalIdentifier:
+ STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier,
+ });
+
+ if (!isDefined(avatarFileField)) {
+ this.logger.log(
+ `avatarFile field not found on person for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ const personRepository = dataSource.getRepository(
+ 'person',
+ { shouldBypassPermissionChecks: true },
+ );
+
+ let candidateCount = 0;
+ let migratedCount = 0;
+ let skippedCount = 0;
+ let failedCount = 0;
+
+ let cursor = MIN_UUID;
+ let persons = await this.findPersonsWithAvatarUrlBatch({
+ personRepository,
+ cursor,
+ });
+
+ while (persons.length > 0) {
+ cursor = persons[persons.length - 1].id;
+
+ for (const person of persons) {
+ const avatarUrl = person.avatarUrl;
+
+ if (
+ !isNonEmptyString(avatarUrl) ||
+ isNonEmptyArray(person.avatarFile)
+ ) {
+ continue;
+ }
+
+ if (isDryRun) {
+ candidateCount++;
+
+ continue;
+ }
+
+ const result = await this.migratePersonAvatar({
+ personId: person.id,
+ avatarUrl,
+ workspaceId,
+ fieldMetadataUniversalIdentifier: avatarFileField.universalIdentifier,
+ personRepository,
+ });
+
+ if (result === 'migrated') {
+ migratedCount++;
+ } else if (result === 'skipped') {
+ skippedCount++;
+ } else {
+ failedCount++;
+ }
+ }
+
+ persons = await this.findPersonsWithAvatarUrlBatch({
+ personRepository,
+ cursor,
+ });
+ }
+
+ if (isDryRun) {
+ if (candidateCount > 0) {
+ this.logger.log(
+ `[DRY RUN] person avatarUrl -> avatarFile for workspace ${workspaceId}: ${candidateCount} candidate(s) would be attempted (download/upload not performed, so migrated/skipped/failed is unknown)`,
+ );
+ }
+
+ return;
+ }
+
+ if (migratedCount > 0 || skippedCount > 0 || failedCount > 0) {
+ this.logger.log(
+ `person avatarUrl -> avatarFile for workspace ${workspaceId}: ${migratedCount} migrated, ${skippedCount} skipped (unreachable/non-image), ${failedCount} failed`,
+ );
+ }
+ }
+
+ private async findPersonsWithAvatarUrlBatch({
+ personRepository,
+ cursor,
+ }: {
+ personRepository: WorkspaceRepository;
+ cursor: string;
+ }): Promise {
+ return personRepository.find({
+ select: ['id', 'avatarUrl', 'avatarFile'],
+ where: { id: MoreThan(cursor), avatarUrl: Not(IsNull()) },
+ order: { id: 'ASC' },
+ take: PERSON_BATCH_SIZE,
+ });
+ }
+
+ private async migratePersonAvatar({
+ personId,
+ avatarUrl,
+ workspaceId,
+ fieldMetadataUniversalIdentifier,
+ personRepository,
+ }: {
+ personId: string;
+ avatarUrl: string;
+ workspaceId: string;
+ fieldMetadataUniversalIdentifier: string;
+ personRepository: WorkspaceRepository;
+ }): Promise<'migrated' | 'skipped' | 'failed'> {
+ const imageData = await this.downloadImage({
+ imageUrl: avatarUrl,
+ personId,
+ workspaceId,
+ });
+
+ if (!isDefined(imageData)) {
+ return 'skipped';
+ }
+
+ const filename = `avatar.${imageData.extension}`;
+
+ const uploadedFile = await this.uploadAvatarFile({
+ buffer: imageData.buffer,
+ filename,
+ workspaceId,
+ fieldMetadataUniversalIdentifier,
+ personId,
+ });
+
+ if (!isDefined(uploadedFile)) {
+ return 'failed';
+ }
+
+ try {
+ await personRepository.update(personId, {
+ avatarFile: [
+ {
+ fileId: uploadedFile.id,
+ label: filename,
+ extension: imageData.extension,
+ },
+ ],
+ });
+
+ return 'migrated';
+ } catch (error) {
+ this.logger.warn(
+ `Failed to attach migrated avatar for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ const isFileReferencedByPerson = await this.isAvatarFileReferenced({
+ personId,
+ fileId: uploadedFile.id,
+ workspaceId,
+ personRepository,
+ });
+
+ if (isFileReferencedByPerson) {
+ return 'migrated';
+ }
+
+ await this.safeDeleteUploadedFile(uploadedFile.id, workspaceId);
+
+ return 'failed';
+ }
+ }
+
+ private async uploadAvatarFile({
+ buffer,
+ filename,
+ workspaceId,
+ fieldMetadataUniversalIdentifier,
+ personId,
+ }: {
+ buffer: Buffer;
+ filename: string;
+ workspaceId: string;
+ fieldMetadataUniversalIdentifier: string;
+ personId: string;
+ }): Promise {
+ try {
+ return await this.filesFieldService.uploadFile({
+ file: buffer,
+ filename,
+ workspaceId,
+ fieldMetadataUniversalIdentifier,
+ });
+ } catch (error) {
+ this.logger.warn(
+ `Failed to upload migrated avatar for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ return undefined;
+ }
+ }
+
+ private async isAvatarFileReferenced({
+ personId,
+ fileId,
+ workspaceId,
+ personRepository,
+ }: {
+ personId: string;
+ fileId: string;
+ workspaceId: string;
+ personRepository: WorkspaceRepository;
+ }): Promise {
+ try {
+ const person = await personRepository.findOne({
+ select: ['id', 'avatarFile'],
+ where: { id: personId },
+ });
+
+ return (
+ person?.avatarFile?.some((file) => file.fileId === fileId) ?? false
+ );
+ } catch (error) {
+ this.logger.warn(
+ `Failed to verify avatar file reference for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ return true;
+ }
+ }
+
+ private async downloadImage({
+ imageUrl,
+ personId,
+ workspaceId,
+ }: {
+ imageUrl: string;
+ personId: string;
+ workspaceId: string;
+ }): Promise<{ buffer: Buffer; extension: string } | undefined> {
+ const httpClient = this.secureHttpClientService.getHttpClient({
+ retries: 2,
+ shouldResetTimeout: true,
+ });
+
+ try {
+ return await fetchImageWithTypeFromUrl(imageUrl, httpClient);
+ } catch {
+ this.logger.warn(
+ `Failed to fetch avatar image for person ${personId} in workspace ${workspaceId}, skipping`,
+ );
+
+ return undefined;
+ }
+ }
+
+ private async safeDeleteUploadedFile(
+ fileId: string,
+ workspaceId: string,
+ ): Promise {
+ try {
+ await this.filesFieldService.deleteFilesFieldFile({
+ fileId,
+ workspaceId,
+ });
+ } catch (error) {
+ this.logger.warn(
+ `Failed to delete orphaned avatar file ${fileId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
index 631ab4fe43..83f1e28315 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
@@ -22,6 +22,7 @@ import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module';
import { V2_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module';
import { V2_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module';
+import { V2_22_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module';
@Module({
imports: [
@@ -47,6 +48,7 @@ import { V2_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_19_UpgradeVersionCommandModule,
V2_20_UpgradeVersionCommandModule,
V2_21_UpgradeVersionCommandModule,
+ V2_22_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts b/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts
index 1f83b3ccb0..58bcaddc2b 100644
--- a/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts
+++ b/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts
@@ -9,6 +9,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
const workspaceId = '20202020-0000-0000-0000-000000000000';
const personNameFieldId = 'person-name-field-id';
+const personAvatarFileFieldId = 'person-avatar-file-field-id';
const companyNameFieldId = 'company-name-field-id';
const companyDomainNameFieldId = 'company-domain-name-field-id';
const customObjectNameFieldId = 'custom-object-name-field-id';
@@ -25,9 +26,9 @@ export const mockFlatObjectMetadatas: FlatObjectMetadata[] = [
icon: 'test-person-icon',
isSearchable: true,
labelIdentifierFieldMetadataId: personNameFieldId,
- imageIdentifierFieldMetadataId: null,
+ imageIdentifierFieldMetadataId: personAvatarFileFieldId,
workspaceId,
- fieldIds: [personNameFieldId],
+ fieldIds: [personNameFieldId, personAvatarFileFieldId],
universalIdentifier: 'person-universal-id',
applicationId: workspaceId,
}),
@@ -41,7 +42,7 @@ export const mockFlatObjectMetadatas: FlatObjectMetadata[] = [
icon: 'test-company-icon',
isSearchable: true,
labelIdentifierFieldMetadataId: companyNameFieldId,
- imageIdentifierFieldMetadataId: null,
+ imageIdentifierFieldMetadataId: companyDomainNameFieldId,
workspaceId,
fieldIds: [companyNameFieldId, companyDomainNameFieldId],
universalIdentifier: 'company-universal-id',
@@ -114,6 +115,19 @@ export const mockFlatFieldMetadataMaps: FlatEntityMaps = {
universalIdentifier: 'person-name-field-universal-id',
applicationId: workspaceId,
}),
+ 'person-avatar-file-field-universal-id': getFlatFieldMetadataMock({
+ id: personAvatarFileFieldId,
+ type: FieldMetadataType.FILES,
+ icon: 'test-field-icon',
+ name: 'avatarFile',
+ label: 'Avatar',
+ description: null,
+ defaultValue: null,
+ objectMetadataId: '20202020-8dec-43d5-b2ff-6eef05095bec',
+ workspaceId,
+ universalIdentifier: 'person-avatar-file-field-universal-id',
+ applicationId: workspaceId,
+ }),
'company-name-field-universal-id': getFlatFieldMetadataMock({
id: companyNameFieldId,
type: FieldMetadataType.TEXT,
@@ -173,6 +187,7 @@ export const mockFlatFieldMetadataMaps: FlatEntityMaps = {
},
universalIdentifierById: {
[personNameFieldId]: 'person-name-field-universal-id',
+ [personAvatarFileFieldId]: 'person-avatar-file-field-universal-id',
[companyNameFieldId]: 'company-name-field-universal-id',
[companyDomainNameFieldId]: 'company-domain-name-field-universal-id',
[customObjectNameFieldId]: 'custom-object-name-field-universal-id',
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.module.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.module.ts
index 4637a6a0de..a3e99aee08 100644
--- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { TimelineCalendarEventResolver } from 'src/engine/core-modules/calendar/timeline-calendar-event.resolver';
import { TimelineCalendarEventService } from 'src/engine/core-modules/calendar/timeline-calendar-event.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -11,6 +12,7 @@ import { UserModule } from 'src/engine/core-modules/user/user.module';
@Module({
imports: [
+ FileUrlModule,
UserModule,
RelatedPersonIdsModule,
TypeOrmModule.forFeature([
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts
index b6a50caea6..4da2f6bf84 100644
--- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts
@@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { CalendarChannelVisibility } from 'twenty-shared/types';
+import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@@ -29,6 +30,7 @@ describe('TimelineCalendarEventService', () => {
let mockConnectedAccountRepository: { find: jest.Mock };
let mockUserWorkspaceRepository: { findOne: jest.Mock };
let mockWorkspaceMemberRepository: { findOne: jest.Mock };
+ let mockFileUrlService: { signFirstFilesFieldFileUrl: jest.Mock };
const mockCalendarEvent: Partial = {
id: '1',
@@ -63,6 +65,10 @@ describe('TimelineCalendarEventService', () => {
findOne: jest.fn().mockResolvedValue(null),
};
+ mockFileUrlService = {
+ signFirstFilesFieldFileUrl: jest.fn().mockResolvedValue(null),
+ };
+
const mockGlobalWorkspaceOrmManager = {
getRepository: jest
.fn()
@@ -101,6 +107,10 @@ describe('TimelineCalendarEventService', () => {
provide: RelatedPersonIdsService,
useValue: { getRelatedPersonIds: jest.fn().mockResolvedValue([]) },
},
+ {
+ provide: FileUrlService,
+ useValue: mockFileUrlService,
+ },
],
}).compile();
@@ -250,4 +260,113 @@ describe('TimelineCalendarEventService', () => {
'Test Description',
);
});
+
+ it('should resolve the participant avatar from the signed avatarFile URL over the legacy avatarUrl', async () => {
+ const signedAvatarFileUrl = 'https://files.example.com/signed-avatar.png';
+
+ mockFileUrlService.signFirstFilesFieldFileUrl.mockResolvedValue(
+ signedAvatarFileUrl,
+ );
+
+ mockCalendarEventRepository.find.mockResolvedValue([
+ { id: '1', startsAt: new Date() },
+ ]);
+ mockCalendarEventRepository.findAndCount.mockResolvedValue([
+ [
+ {
+ ...mockCalendarEvent,
+ calendarEventParticipants: [
+ {
+ personId: 'person-1',
+ handle: 'john@example.com',
+ person: {
+ id: 'person-1',
+ name: { firstName: 'John', lastName: 'Doe' },
+ avatarFile: [{ fileId: 'file-1' }],
+ avatarUrl: 'https://legacy.example.com/avatar.png',
+ },
+ },
+ ],
+ calendarChannelEventAssociations: [
+ { calendarChannelId: 'channel-1' },
+ ],
+ },
+ ],
+ 1,
+ ]);
+ mockCalendarChannelCoreRepository.find.mockResolvedValue([
+ {
+ id: 'channel-1',
+ visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
+ connectedAccountId: 'connected-account-1',
+ },
+ ]);
+
+ const result = await service.getCalendarEventsFromPersonIds({
+ currentWorkspaceMemberId: 'current-workspace-member-id',
+ personIds: ['person-1'],
+ workspaceId: 'test-workspace-id',
+ page: 1,
+ pageSize: 10,
+ });
+
+ expect(mockFileUrlService.signFirstFilesFieldFileUrl).toHaveBeenCalledWith({
+ filesFieldValue: [{ fileId: 'file-1' }],
+ workspaceId: 'test-workspace-id',
+ });
+ expect(result.timelineCalendarEvents[0].participants[0].avatarUrl).toBe(
+ signedAvatarFileUrl,
+ );
+ });
+
+ it('should fall back to the legacy avatarUrl when no avatarFile is signed', async () => {
+ mockFileUrlService.signFirstFilesFieldFileUrl.mockResolvedValue(null);
+
+ const legacyAvatarUrl = 'https://legacy.example.com/avatar.png';
+
+ mockCalendarEventRepository.find.mockResolvedValue([
+ { id: '1', startsAt: new Date() },
+ ]);
+ mockCalendarEventRepository.findAndCount.mockResolvedValue([
+ [
+ {
+ ...mockCalendarEvent,
+ calendarEventParticipants: [
+ {
+ personId: 'person-1',
+ handle: 'john@example.com',
+ person: {
+ id: 'person-1',
+ name: { firstName: 'John', lastName: 'Doe' },
+ avatarUrl: legacyAvatarUrl,
+ },
+ },
+ ],
+ calendarChannelEventAssociations: [
+ { calendarChannelId: 'channel-1' },
+ ],
+ },
+ ],
+ 1,
+ ]);
+ mockCalendarChannelCoreRepository.find.mockResolvedValue([
+ {
+ id: 'channel-1',
+ visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
+ connectedAccountId: 'connected-account-1',
+ },
+ ]);
+
+ const result = await service.getCalendarEventsFromPersonIds({
+ currentWorkspaceMemberId: 'current-workspace-member-id',
+ personIds: ['person-1'],
+ workspaceId: 'test-workspace-id',
+ page: 1,
+ pageSize: 10,
+ });
+
+ expect(result.timelineCalendarEvents[0].participants[0].avatarUrl).toBe(
+ legacyAvatarUrl,
+ );
+ });
});
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
index 395c28a276..32ef6f239b 100644
--- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
@@ -8,6 +8,7 @@ import { Any, In, type Repository } from 'typeorm';
import { CalendarChannelVisibility } from 'twenty-shared/types';
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
import { type TimelineCalendarEventsWithTotalDTO } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-events-with-total.dto';
+import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -28,6 +29,7 @@ export class TimelineCalendarEventService {
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository,
private readonly relatedPersonIdsService: RelatedPersonIdsService,
+ private readonly fileUrlService: FileUrlService,
) {}
async getCalendarEventsFromPersonIds({
@@ -182,74 +184,91 @@ export class TimelineCalendarEventService {
(a, b) => ids.indexOf(a.id) - ids.indexOf(b.id),
);
- const timelineCalendarEvents = orderedEvents.map((event) => {
- const participants = event.calendarEventParticipants.map(
- (participant) => ({
- calendarEventId: event.id,
- personId: participant.personId ?? null,
- workspaceMemberId: participant.workspaceMemberId ?? null,
- firstName:
- participant.person?.name?.firstName ||
- participant.workspaceMember?.name.firstName ||
- '',
- lastName:
- participant.person?.name?.lastName ||
- participant.workspaceMember?.name.lastName ||
- '',
- displayName:
- participant.person?.name?.firstName ||
- participant.person?.name?.lastName ||
- participant.workspaceMember?.name.firstName ||
- participant.workspaceMember?.name.lastName ||
- participant.displayName ||
- participant.handle ||
- '',
- avatarUrl:
- participant.person?.avatarUrl ||
- participant.workspaceMember?.avatarUrl ||
- '',
- handle: participant.handle ?? '',
- }),
- );
+ const timelineCalendarEventPromises = orderedEvents.map(
+ async (event) => {
+ const participantPromises = event.calendarEventParticipants.map(
+ async (participant) => {
+ const personAvatarFileUrl =
+ await this.fileUrlService.signFirstFilesFieldFileUrl({
+ filesFieldValue: participant.person?.avatarFile,
+ workspaceId,
+ });
- const hasFullAccess = event.calendarChannelEventAssociations.some(
- (association) => {
- const channel = calendarChannelMap.get(
- association.calendarChannelId,
- );
+ return {
+ calendarEventId: event.id,
+ personId: participant.personId ?? null,
+ workspaceMemberId: participant.workspaceMemberId ?? null,
+ firstName:
+ participant.person?.name?.firstName ||
+ participant.workspaceMember?.name.firstName ||
+ '',
+ lastName:
+ participant.person?.name?.lastName ||
+ participant.workspaceMember?.name.lastName ||
+ '',
+ displayName:
+ participant.person?.name?.firstName ||
+ participant.person?.name?.lastName ||
+ participant.workspaceMember?.name.firstName ||
+ participant.workspaceMember?.name.lastName ||
+ participant.displayName ||
+ participant.handle ||
+ '',
+ avatarUrl:
+ personAvatarFileUrl ||
+ participant.person?.avatarUrl ||
+ participant.workspaceMember?.avatarUrl ||
+ '',
+ handle: participant.handle ?? '',
+ };
+ },
+ );
- return (
- channel?.visibility === 'SHARE_EVERYTHING' ||
- channel?.isOwnedByCurrentUser
- );
- },
- );
+ const participants = await Promise.all(participantPromises);
- const visibility = hasFullAccess
- ? CalendarChannelVisibility.SHARE_EVERYTHING
- : CalendarChannelVisibility.METADATA;
+ const hasFullAccess = event.calendarChannelEventAssociations.some(
+ (association) => {
+ const channel = calendarChannelMap.get(
+ association.calendarChannelId,
+ );
- return {
- ...omit(event, [
- 'calendarEventParticipants',
- 'calendarChannelEventAssociations',
- ]),
- title:
- visibility === CalendarChannelVisibility.METADATA
- ? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
- : (event.title ?? ''),
- description:
- visibility === CalendarChannelVisibility.METADATA
- ? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
- : (event.description ?? ''),
- startsAt: event.startsAt as unknown as Date,
- endsAt: event.endsAt as unknown as Date,
- participants,
- visibility,
- location: event.location ?? '',
- conferenceSolution: event.conferenceSolution ?? '',
- };
- });
+ return (
+ channel?.visibility === 'SHARE_EVERYTHING' ||
+ channel?.isOwnedByCurrentUser
+ );
+ },
+ );
+
+ const visibility = hasFullAccess
+ ? CalendarChannelVisibility.SHARE_EVERYTHING
+ : CalendarChannelVisibility.METADATA;
+
+ return {
+ ...omit(event, [
+ 'calendarEventParticipants',
+ 'calendarChannelEventAssociations',
+ ]),
+ title:
+ visibility === CalendarChannelVisibility.METADATA
+ ? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
+ : (event.title ?? ''),
+ description:
+ visibility === CalendarChannelVisibility.METADATA
+ ? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
+ : (event.description ?? ''),
+ startsAt: event.startsAt as unknown as Date,
+ endsAt: event.endsAt as unknown as Date,
+ participants,
+ visibility,
+ location: event.location ?? '',
+ conferenceSolution: event.conferenceSolution ?? '',
+ };
+ },
+ );
+
+ const timelineCalendarEvents = await Promise.all(
+ timelineCalendarEventPromises,
+ );
return {
totalNumberOfCalendarEvents,
diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts
index 8f3dbf4127..e4911aed37 100644
--- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts
@@ -4,8 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { buffer as streamToBuffer } from 'node:stream/consumers';
import { isNonEmptyString } from '@sniptt/guards';
-import { FileTypeParser } from 'file-type';
-import { detectPdf } from '@file-type/pdf';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
@@ -31,7 +29,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
-import { getImageBufferFromUrl } from 'src/utils/image';
+import { fetchImageWithTypeFromUrl } from 'src/utils/image';
@Injectable()
export class FileCorePictureService {
@@ -241,16 +239,7 @@ export class FileCorePictureService {
shouldResetTimeout: true,
});
- const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
-
- const parser = new FileTypeParser({ customDetectors: [detectPdf] });
- const type = await parser.fromBuffer(buffer);
-
- if (!isDefined(type) || !type.mime.startsWith('image/')) {
- return undefined;
- }
-
- return { buffer, extension: type.ext };
+ return await fetchImageWithTypeFromUrl(imageUrl, httpClient);
} catch (error) {
this.logger.warn(
`Failed to fetch image from URL: ${imageUrl} — ${error instanceof Error ? error.message : String(error)}`,
diff --git a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts
index b084619afe..76384d98c9 100644
--- a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
+import { type FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -32,6 +33,26 @@ export class FileUrlService {
});
}
+ async signFirstFilesFieldFileUrl({
+ filesFieldValue,
+ workspaceId,
+ }: {
+ filesFieldValue: FileOutput[] | null | undefined;
+ workspaceId: string;
+ }): Promise {
+ const firstFileId = filesFieldValue?.[0]?.fileId;
+
+ if (!isDefined(firstFileId)) {
+ return null;
+ }
+
+ return this.signFileByIdUrl({
+ fileId: firstFileId,
+ workspaceId,
+ fileFolder: FileFolder.FilesField,
+ });
+ }
+
async signFileByIdUrl({
fileId,
workspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/messaging/services/timeline-messaging.service.ts b/packages/twenty-server/src/engine/core-modules/messaging/services/timeline-messaging.service.ts
index ed2f578fdd..3937fca16b 100644
--- a/packages/twenty-server/src/engine/core-modules/messaging/services/timeline-messaging.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/messaging/services/timeline-messaging.service.ts
@@ -7,6 +7,7 @@ import {
} from 'twenty-shared/types';
import { In, type Repository } from 'typeorm';
+import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { type TimelineThreadDTO } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -27,6 +28,7 @@ export class TimelineMessagingService {
private readonly connectedAccountRepository: Repository,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository,
+ private readonly fileUrlService: FileUrlService,
) {}
public async getAndCountMessageThreads(
@@ -159,34 +161,48 @@ export class TimelineMessagingService {
(b.message.receivedAt ?? new Date()).getTime(),
);
- const threadParticipantsWithCompositeFields =
- orderedThreadParticipants.map((threadParticipant) => ({
- ...threadParticipant,
- person: {
- id: threadParticipant.person?.id,
- name: {
- //oxlint-disable-next-line
- //@ts-ignore
- firstName: threadParticipant.person?.nameFirstName,
- //oxlint-disable-next-line
- //@ts-ignore
- lastName: threadParticipant.person?.nameLastName,
+ const threadParticipantPromises = orderedThreadParticipants.map(
+ async (threadParticipant) => {
+ const personAvatarFileUrl =
+ await this.fileUrlService.signFirstFilesFieldFileUrl({
+ filesFieldValue: threadParticipant.person?.avatarFile,
+ workspaceId,
+ });
+
+ return {
+ ...threadParticipant,
+ person: {
+ id: threadParticipant.person?.id,
+ name: {
+ //oxlint-disable-next-line
+ //@ts-ignore
+ firstName: threadParticipant.person?.nameFirstName,
+ //oxlint-disable-next-line
+ //@ts-ignore
+ lastName: threadParticipant.person?.nameLastName,
+ },
+ avatarUrl:
+ personAvatarFileUrl || threadParticipant.person?.avatarUrl,
},
- avatarUrl: threadParticipant.person?.avatarUrl,
- },
- workspaceMember: {
- id: threadParticipant.workspaceMember?.id,
- name: {
- //oxlint-disable-next-line
- //@ts-ignore
- firstName: threadParticipant.workspaceMember?.nameFirstName,
- //oxlint-disable-next-line
- //@ts-ignore
- lastName: threadParticipant.workspaceMember?.nameLastName,
+ workspaceMember: {
+ id: threadParticipant.workspaceMember?.id,
+ name: {
+ //oxlint-disable-next-line
+ //@ts-ignore
+ firstName: threadParticipant.workspaceMember?.nameFirstName,
+ //oxlint-disable-next-line
+ //@ts-ignore
+ lastName: threadParticipant.workspaceMember?.nameLastName,
+ },
+ avatarUrl: threadParticipant.workspaceMember?.avatarUrl,
},
- avatarUrl: threadParticipant.workspaceMember?.avatarUrl,
- },
- }));
+ };
+ },
+ );
+
+ const threadParticipantsWithCompositeFields = await Promise.all(
+ threadParticipantPromises,
+ );
return threadParticipantsWithCompositeFields.reduce(
(threadParticipantsAcc, threadParticipant) => {
diff --git a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts
index c2692a1082..ad4cde4446 100644
--- a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { GetMessagesService } from 'src/engine/core-modules/messaging/services/get-messages.service';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/services/timeline-messaging.service';
import { TimelineMessagingResolver } from 'src/engine/core-modules/messaging/timeline-messaging.resolver';
@@ -17,6 +18,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
@Module({
imports: [
WorkspaceDataSourceModule,
+ FileUrlModule,
UserModule,
ConnectedAccountModule,
FeatureFlagModule,
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/get-record-image-identifier.util.spec.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/get-record-image-identifier.util.spec.ts
new file mode 100644
index 0000000000..ea77309638
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/get-record-image-identifier.util.spec.ts
@@ -0,0 +1,232 @@
+import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
+
+import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
+
+const buildFieldMaps = (
+ fields: FlatFieldMetadata[],
+): FlatEntityMaps => {
+ const byUniversalIdentifier: Record = {};
+ const universalIdentifierById: Record = {};
+
+ for (const field of fields) {
+ byUniversalIdentifier[field.universalIdentifier] = field;
+ universalIdentifierById[field.id] = field.universalIdentifier;
+ }
+
+ return {
+ byUniversalIdentifier,
+ universalIdentifierById,
+ universalIdentifiersByApplicationId: {},
+ };
+};
+
+const signUrl = (fileId: string, fileFolder: FileFolder) =>
+ `signed:${fileFolder}:${fileId}`;
+
+describe('getRecordImageIdentifier', () => {
+ it('resolves a LINKS image identifier to a favicon url', async () => {
+ const domainNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'domain-ui',
+ objectMetadataId: 'company-id',
+ id: 'domain-id',
+ name: 'domainName',
+ type: FieldMetadataType.LINKS,
+ });
+ const company = getFlatObjectMetadataMock({
+ universalIdentifier: 'company-ui',
+ id: 'company-id',
+ nameSingular: 'company',
+ imageIdentifierFieldMetadataId: 'domain-id',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: { domainName: { primaryLinkUrl: 'twenty.com' } },
+ flatObjectMetadata: company,
+ flatFieldMetadataMaps: buildFieldMaps([domainNameField]),
+ allowRequestsToTwentyIcons: true,
+ });
+
+ expect(result).toBe('https://twenty-icons.com/twenty.com');
+ });
+
+ it('returns null for a LINKS image identifier when twenty-icons requests are disabled', async () => {
+ const domainNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'domain-ui',
+ objectMetadataId: 'company-id',
+ id: 'domain-id',
+ name: 'domainName',
+ type: FieldMetadataType.LINKS,
+ });
+ const company = getFlatObjectMetadataMock({
+ universalIdentifier: 'company-ui',
+ id: 'company-id',
+ nameSingular: 'company',
+ imageIdentifierFieldMetadataId: 'domain-id',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: { domainName: { primaryLinkUrl: 'twenty.com' } },
+ flatObjectMetadata: company,
+ flatFieldMetadataMaps: buildFieldMaps([domainNameField]),
+ allowRequestsToTwentyIcons: false,
+ });
+
+ expect(result).toBe(null);
+ });
+
+ it('resolves a FILES image identifier to a signed url', async () => {
+ const avatarFileField = getFlatFieldMetadataMock({
+ universalIdentifier: 'avatar-ui',
+ objectMetadataId: 'person-id',
+ id: 'avatar-id',
+ name: 'avatarFile',
+ type: FieldMetadataType.FILES,
+ });
+ const person = getFlatObjectMetadataMock({
+ universalIdentifier: 'person-ui',
+ id: 'person-id',
+ nameSingular: 'person',
+ imageIdentifierFieldMetadataId: 'avatar-id',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: { avatarFile: [{ fileId: 'file-1' }] },
+ flatObjectMetadata: person,
+ flatFieldMetadataMaps: buildFieldMaps([avatarFileField]),
+ allowRequestsToTwentyIcons: true,
+ signUrl,
+ });
+
+ expect(result).toBe(`signed:${FileFolder.FilesField}:file-1`);
+ });
+
+ it('returns null for a FILES image identifier when signUrl is not provided', async () => {
+ const avatarFileField = getFlatFieldMetadataMock({
+ universalIdentifier: 'avatar-ui',
+ objectMetadataId: 'person-id',
+ id: 'avatar-id',
+ name: 'avatarFile',
+ type: FieldMetadataType.FILES,
+ });
+ const person = getFlatObjectMetadataMock({
+ universalIdentifier: 'person-ui',
+ id: 'person-id',
+ nameSingular: 'person',
+ imageIdentifierFieldMetadataId: 'avatar-id',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: { avatarFile: [{ fileId: 'file-1' }] },
+ flatObjectMetadata: person,
+ flatFieldMetadataMaps: buildFieldMaps([avatarFileField]),
+ allowRequestsToTwentyIcons: true,
+ });
+
+ expect(result).toBe(null);
+ });
+
+ it('prefers the overrides column over the base image identifier', async () => {
+ const baseTextField = getFlatFieldMetadataMock({
+ universalIdentifier: 'text-ui',
+ objectMetadataId: 'custom-id',
+ id: 'text-id',
+ name: 'baseText',
+ type: FieldMetadataType.TEXT,
+ });
+ const domainNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'domain-ui',
+ objectMetadataId: 'custom-id',
+ id: 'domain-id',
+ name: 'domainName',
+ type: FieldMetadataType.LINKS,
+ });
+ const customObject = getFlatObjectMetadataMock({
+ universalIdentifier: 'custom-ui',
+ id: 'custom-id',
+ nameSingular: 'custom',
+ imageIdentifierFieldMetadataId: 'text-id',
+ overrides: { imageIdentifierFieldMetadataId: 'domain-id' },
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: {
+ baseText: 'ignored',
+ domainName: { primaryLinkUrl: 'acme.com' },
+ },
+ flatObjectMetadata: customObject,
+ flatFieldMetadataMaps: buildFieldMaps([baseTextField, domainNameField]),
+ allowRequestsToTwentyIcons: true,
+ });
+
+ expect(result).toBe('https://twenty-icons.com/acme.com');
+ });
+
+ it('respects an explicit null override (cleared image identifier)', async () => {
+ const domainNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'domain-ui',
+ objectMetadataId: 'custom-id',
+ id: 'domain-id',
+ name: 'domainName',
+ type: FieldMetadataType.LINKS,
+ });
+ const customObject = getFlatObjectMetadataMock({
+ universalIdentifier: 'custom-ui',
+ id: 'custom-id',
+ nameSingular: 'custom',
+ imageIdentifierFieldMetadataId: 'domain-id',
+ overrides: { imageIdentifierFieldMetadataId: null },
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: { domainName: { primaryLinkUrl: 'acme.com' } },
+ flatObjectMetadata: customObject,
+ flatFieldMetadataMaps: buildFieldMaps([domainNameField]),
+ allowRequestsToTwentyIcons: true,
+ });
+
+ expect(result).toBe(null);
+ });
+
+ it('returns null when the image identifier field cannot be resolved', async () => {
+ const customObject = getFlatObjectMetadataMock({
+ universalIdentifier: 'custom-ui',
+ id: 'custom-id',
+ nameSingular: 'custom',
+ imageIdentifierFieldMetadataId: 'missing-id',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: {},
+ flatObjectMetadata: customObject,
+ flatFieldMetadataMaps: buildFieldMaps([]),
+ allowRequestsToTwentyIcons: true,
+ });
+
+ expect(result).toBe(null);
+ });
+
+ it('signs the workspace member avatar url as a CorePicture (exception)', async () => {
+ const fileId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
+ const workspaceMember = getFlatObjectMetadataMock({
+ universalIdentifier: 'workspace-member-ui',
+ id: 'workspace-member-id',
+ nameSingular: 'workspaceMember',
+ });
+
+ const result = await getRecordImageIdentifier({
+ record: {
+ avatarUrl: `https://example.com/file/${FileFolder.CorePicture}/${fileId}`,
+ },
+ flatObjectMetadata: workspaceMember,
+ flatFieldMetadataMaps: buildFieldMaps([]),
+ allowRequestsToTwentyIcons: true,
+ signUrl,
+ });
+
+ expect(result).toBe(`signed:${FileFolder.CorePicture}:${fileId}`);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts
index 4f5012d0d3..ba0f26493f 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts
@@ -1,18 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
-import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
+import { getLinkFaviconUrl, isDefined } from 'twenty-shared/utils';
-import { FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.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 { FileFolder } from 'twenty-shared/types';
+import { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
+import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
type GetRecordImageIdentifierOptions = {
record: Record;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps;
+ allowRequestsToTwentyIcons: boolean;
signUrl?: (
fileId: string,
fileFolder: FileFolder,
@@ -23,35 +24,16 @@ export const getRecordImageIdentifier = async ({
record,
flatObjectMetadata,
flatFieldMetadataMaps,
+ allowRequestsToTwentyIcons,
signUrl,
}: GetRecordImageIdentifierOptions): Promise => {
- if (flatObjectMetadata.nameSingular === 'company') {
- const domainNameObj = record.domainName as
- | { primaryLinkUrl?: string }
- | undefined;
- const domainNamePrimaryLinkUrl = domainNameObj?.primaryLinkUrl;
-
- return domainNamePrimaryLinkUrl
- ? getLogoUrlFromDomainName(domainNamePrimaryLinkUrl) || null
- : null;
- }
-
- //TODO: Temporary solution before imageIdentifier refactor
- if (signUrl && flatObjectMetadata.nameSingular === 'person') {
- const avatarFileId = (record.avatarFile as FileOutput[])?.[0]?.fileId;
- if (!isDefined(avatarFileId)) {
- return null;
- }
- return signUrl(avatarFileId, FileFolder.FilesField);
- }
-
if (
signUrl &&
flatObjectMetadata.nameSingular === 'workspaceMember' &&
- isDefined(record.avatarUrl)
+ isNonEmptyString(record.avatarUrl)
) {
const avatarFileId = extractFileIdFromUrl(
- record.avatarUrl as string,
+ record.avatarUrl,
FileFolder.CorePicture,
);
if (!isDefined(avatarFileId)) {
@@ -60,13 +42,16 @@ export const getRecordImageIdentifier = async ({
return signUrl(avatarFileId, FileFolder.CorePicture);
}
- if (!isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) {
+ const imageIdentifierFieldMetadataId =
+ getEffectiveImageIdentifierFieldMetadataId(flatObjectMetadata);
+
+ if (!isDefined(imageIdentifierFieldMetadataId)) {
return null;
}
const imageIdentifierField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
- flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
+ flatEntityId: imageIdentifierFieldMetadataId,
});
if (!isDefined(imageIdentifierField)) {
@@ -79,15 +64,34 @@ export const getRecordImageIdentifier = async ({
return null;
}
- const rawImageValue = String(imageValue);
+ switch (imageIdentifierField.type) {
+ case FieldMetadataType.FILES: {
+ const fileId = Array.isArray(imageValue)
+ ? imageValue[0]?.fileId
+ : undefined;
- if (!isNonEmptyString(rawImageValue)) {
- return null;
+ if (!isNonEmptyString(fileId) || !isDefined(signUrl)) {
+ return null;
+ }
+
+ return signUrl(fileId, FileFolder.FilesField);
+ }
+ case FieldMetadataType.LINKS: {
+ if (!allowRequestsToTwentyIcons) {
+ return null;
+ }
+
+ const primaryLinkUrl =
+ typeof imageValue === 'object' && 'primaryLinkUrl' in imageValue
+ ? imageValue.primaryLinkUrl
+ : undefined;
+
+ return isNonEmptyString(primaryLinkUrl)
+ ? getLinkFaviconUrl(primaryLinkUrl) || null
+ : null;
+ }
+ default: {
+ return null;
+ }
}
-
- if (signUrl && flatObjectMetadata.nameSingular === 'workspaceMember') {
- return signUrl(rawImageValue, FileFolder.FilesField);
- }
-
- return rawImageValue;
};
diff --git a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts
index 191a51242d..9c94094fc2 100644
--- a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts
@@ -110,31 +110,31 @@ describe('SearchService', () => {
});
});
- describe('getImageIdentifierColumn', () => {
- it('should return `avatarFile` if the object metadata item is a person', () => {
- const imageIdentifierColumn = service.getImageIdentifierColumn(
+ describe('getImageIdentifierColumns', () => {
+ it('should return the FILES image identifier column for a person object metadata item', () => {
+ const imageIdentifierColumns = service.getImageIdentifierColumns(
mockFlatObjectMetadatas[0],
mockFlatFieldMetadataMaps,
);
- expect(imageIdentifierColumn).toEqual('avatarFile');
+ expect(imageIdentifierColumns).toEqual(['avatarFile']);
});
- it('should return `domainNamePrimaryLinkUrl` column for a company object metadata item', () => {
- const imageIdentifierColumn = service.getImageIdentifierColumn(
+ it('should select only the primaryLinkUrl column of the composite LINKS image identifier for a company object metadata item', () => {
+ const imageIdentifierColumns = service.getImageIdentifierColumns(
mockFlatObjectMetadatas[1],
mockFlatFieldMetadataMaps,
);
- expect(imageIdentifierColumn).toEqual('domainNamePrimaryLinkUrl');
+ expect(imageIdentifierColumns).toEqual(['domainNamePrimaryLinkUrl']);
});
- it('should return the image identifier column', () => {
- const imageIdentifierColumn = service.getImageIdentifierColumn(
+ it('should return the non-composite image identifier column for a regular object metadata item', () => {
+ const imageIdentifierColumns = service.getImageIdentifierColumns(
mockFlatObjectMetadatas[2],
mockFlatFieldMetadataMaps,
);
- expect(imageIdentifierColumn).toEqual('imageIdentifierFieldName');
+ expect(imageIdentifierColumns).toEqual(['imageIdentifierFieldName']);
});
});
diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts
index 10a44a7443..037208a096 100644
--- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts
@@ -4,20 +4,20 @@ import { isNonEmptyString } from '@sniptt/guards';
import chunk from 'lodash.chunk';
import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants';
import {
+ compositeTypeDefinitions,
FieldMetadataType,
FileFolder,
ObjectRecord,
} from 'twenty-shared/types';
import {
escapeForIlike,
- getLogoUrlFromDomainName,
+ getLinkFaviconUrl,
isDefined,
} from 'twenty-shared/utils';
import { Brackets, type ObjectLiteral } from 'typeorm';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
-import { FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import {
decodeCursor,
@@ -39,10 +39,13 @@ import {
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
+import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.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 { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -283,7 +286,7 @@ export class SearchService {
queryParser.applyDeletedAtToBuilder(queryBuilder, filter);
- const imageIdentifierField = this.getImageIdentifierColumn(
+ const imageIdentifierColumns = this.getImageIdentifierColumns(
flatObjectMetadata,
flatFieldMetadataMaps,
);
@@ -294,7 +297,7 @@ export class SearchService {
flatObjectMetadata,
flatFieldMetadataMaps,
),
- ...(imageIdentifierField ? [imageIdentifierField] : []),
+ ...imageIdentifierColumns,
].map((field) => `"${field}"`);
const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery('simple', public.unaccent_immutable(:searchTerms)))`;
@@ -405,7 +408,7 @@ export class SearchService {
queryParser.applyDeletedAtToBuilder(queryBuilder, filter);
- const imageIdentifierField = this.getImageIdentifierColumn(
+ const imageIdentifierColumns = this.getImageIdentifierColumns(
flatObjectMetadata,
flatFieldMetadataMaps,
);
@@ -416,7 +419,7 @@ export class SearchService {
flatObjectMetadata,
flatFieldMetadataMaps,
),
- ...(imageIdentifierField ? [imageIdentifierField] : []),
+ ...imageIdentifierColumns,
].map((field) => `"${field}"`);
queryBuilder.select(fieldsToSelect);
@@ -559,37 +562,60 @@ export class SearchService {
return labelIdentifierFields.map((field) => record[field]).join(' ');
}
- getImageIdentifierColumn(
+ private getEffectiveImageIdentifierFieldMetadata(
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps,
- ) {
- if (flatObjectMetadata.nameSingular === 'company') {
- return 'domainNamePrimaryLinkUrl';
+ ): FlatFieldMetadata | undefined {
+ const imageIdentifierFieldMetadataId =
+ getEffectiveImageIdentifierFieldMetadataId(flatObjectMetadata);
+
+ if (!isDefined(imageIdentifierFieldMetadataId)) {
+ return undefined;
}
- //TODO: Temporary solution before imageIdentifier refactor
- if (flatObjectMetadata.nameSingular === 'person') {
- return 'avatarFile';
- }
-
- if (flatObjectMetadata.nameSingular === 'workspaceMember') {
- return 'avatarUrl';
- }
-
- if (!flatObjectMetadata.imageIdentifierFieldMetadataId) {
- return null;
- }
-
- const imageIdentifierField = findFlatEntityByIdInFlatEntityMaps({
- flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
+ return findFlatEntityByIdInFlatEntityMaps({
+ flatEntityId: imageIdentifierFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
+ }
- if (!isDefined(imageIdentifierField)) {
- return null;
+ getImageIdentifierColumns(
+ flatObjectMetadata: FlatObjectMetadata,
+ flatFieldMetadataMaps: FlatEntityMaps,
+ ): string[] {
+ if (flatObjectMetadata.nameSingular === 'workspaceMember') {
+ return ['avatarUrl'];
}
- return imageIdentifierField.name;
+ const imageIdentifierField = this.getEffectiveImageIdentifierFieldMetadata(
+ flatObjectMetadata,
+ flatFieldMetadataMaps,
+ );
+
+ if (!isDefined(imageIdentifierField)) {
+ return [];
+ }
+
+ const imageIdentifierCompositeType = isCompositeFieldMetadataType(
+ imageIdentifierField.type,
+ )
+ ? compositeTypeDefinitions.get(imageIdentifierField.type)
+ : undefined;
+
+ if (isDefined(imageIdentifierCompositeType)) {
+ return imageIdentifierCompositeType.properties
+ .filter(
+ (compositeProperty) => compositeProperty.name === 'primaryLinkUrl',
+ )
+ .map((compositeProperty) =>
+ computeCompositeColumnName(
+ imageIdentifierField.name,
+ compositeProperty,
+ ),
+ );
+ }
+
+ return [imageIdentifierField.name];
}
private async getImageUrlWithToken(
@@ -610,39 +636,16 @@ export class SearchService {
flatFieldMetadataMaps: FlatEntityMaps,
workspaceId: string,
): Promise {
- const imageIdentifierField = this.getImageIdentifierColumn(
- flatObjectMetadata,
- flatFieldMetadataMaps,
- );
-
- if (
- flatObjectMetadata.nameSingular === 'company' &&
- this.twentyConfigService.get('ALLOW_REQUESTS_TO_TWENTY_ICONS')
- ) {
- return getLogoUrlFromDomainName(record.domainNamePrimaryLinkUrl) || '';
- }
-
- //TODO: Temporary solution before imageIdentifier refactor
- if (flatObjectMetadata.nameSingular === 'person') {
- const avatarFileId = (record.avatarFile as FileOutput[])?.[0]?.fileId;
- if (!isDefined(avatarFileId)) {
- return '';
- }
- return this.getImageUrlWithToken(
- avatarFileId,
- FileFolder.FilesField,
- workspaceId,
- );
- }
-
if (flatObjectMetadata.nameSingular === 'workspaceMember') {
const avatarFileId = extractFileIdFromUrl(
record.avatarUrl,
FileFolder.CorePicture,
);
+
if (!isDefined(avatarFileId)) {
return '';
}
+
return this.getImageUrlWithToken(
avatarFileId,
FileFolder.CorePicture,
@@ -650,14 +653,58 @@ export class SearchService {
);
}
- return imageIdentifierField &&
- isNonEmptyString(record[imageIdentifierField])
- ? this.getImageUrlWithToken(
- record[imageIdentifierField],
+ const imageIdentifierField = this.getEffectiveImageIdentifierFieldMetadata(
+ flatObjectMetadata,
+ flatFieldMetadataMaps,
+ );
+
+ if (!isDefined(imageIdentifierField)) {
+ return '';
+ }
+
+ switch (imageIdentifierField.type) {
+ case FieldMetadataType.FILES: {
+ const avatarFileId = record[imageIdentifierField.name]?.[0]?.fileId;
+
+ if (!isNonEmptyString(avatarFileId)) {
+ return '';
+ }
+
+ return this.getImageUrlWithToken(
+ avatarFileId,
FileFolder.FilesField,
workspaceId,
- )
- : '';
+ );
+ }
+ case FieldMetadataType.LINKS: {
+ if (!this.twentyConfigService.get('ALLOW_REQUESTS_TO_TWENTY_ICONS')) {
+ return '';
+ }
+
+ const primaryLinkUrlProperty = compositeTypeDefinitions
+ .get(FieldMetadataType.LINKS)
+ ?.properties.find((property) => property.name === 'primaryLinkUrl');
+
+ if (!isDefined(primaryLinkUrlProperty)) {
+ return '';
+ }
+
+ const primaryLinkUrl =
+ record[
+ computeCompositeColumnName(
+ imageIdentifierField.name,
+ primaryLinkUrlProperty,
+ )
+ ];
+
+ return isNonEmptyString(primaryLinkUrl)
+ ? getLinkFaviconUrl(primaryLinkUrl) || ''
+ : '';
+ }
+ default: {
+ return '';
+ }
+ }
}
computeEdges({
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/overridable-and-translatable-properties.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/overridable-and-translatable-properties.constant.spec.ts.snap
index 66c736d1f7..a643ff312b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/overridable-and-translatable-properties.constant.spec.ts.snap
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/overridable-and-translatable-properties.constant.spec.ts.snap
@@ -33,6 +33,7 @@ exports[`registry-derived override property maps derives the overridable propert
"icon",
"labelPlural",
"labelSingular",
+ "imageIdentifierFieldMetadataId",
],
"objectPermission": [],
"pageLayout": [],
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts
index 546b91f748..432b01e7ef 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts
@@ -279,6 +279,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
// @ts-expect-error remove once https://github.com/twentyhq/core-team-issues/issues/2172 has been resolved
universalProperty: 'imageIdentifierFieldMetadataUniversalIdentifier',
+ isOverridable: true,
},
targetTableName: {
toCompare: false,
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts
index 3e7f876d96..e0e2024ed3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts
@@ -13,6 +13,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'namePlural',
'nameSingular',
'labelIdentifierFieldMetadataId',
+ 'imageIdentifierFieldMetadataId',
],
standard: [
'color',
@@ -22,6 +23,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'isSearchable',
'labelPlural',
'labelSingular',
+ 'imageIdentifierFieldMetadataId',
],
} as const satisfies Record<
'standard' | 'custom',
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts
index 184a260063..74501f054f 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts
@@ -1,5 +1,6 @@
import {
isDefined,
+ isImageIdentifierFieldMetadataType,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
@@ -63,6 +64,52 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
);
}
+ const requestedImageIdentifierFieldMetadataId =
+ rawUpdateObjectInput.update.imageIdentifierFieldMetadataId;
+
+ if (isDefined(requestedImageIdentifierFieldMetadataId)) {
+ const imageIdentifierFlatFieldMetadata =
+ findFlatEntityByIdInFlatEntityMaps({
+ flatEntityMaps: flatFieldMetadataMaps,
+ flatEntityId: requestedImageIdentifierFieldMetadataId,
+ });
+
+ if (!isDefined(imageIdentifierFlatFieldMetadata)) {
+ throw new ObjectMetadataException(
+ 'Field declared as image identifier not found',
+ ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
+ );
+ }
+
+ if (
+ imageIdentifierFlatFieldMetadata.objectMetadataId !==
+ existingFlatObjectMetadata.id
+ ) {
+ throw new ObjectMetadataException(
+ 'Field declared as image identifier does not belong to this object',
+ ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
+ );
+ }
+
+ if (
+ !isImageIdentifierFieldMetadataType(
+ imageIdentifierFlatFieldMetadata.type,
+ )
+ ) {
+ throw new ObjectMetadataException(
+ 'Field cannot be used as image identifier due to its type: should be of type Files or Links',
+ ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
+ );
+ }
+
+ if (!imageIdentifierFlatFieldMetadata.isActive) {
+ throw new ObjectMetadataException(
+ 'Field cannot be used as image identifier because it is deactivated',
+ ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
+ );
+ }
+ }
+
const isStandardObject = belongsToTwentyStandardApp(
existingFlatObjectMetadata,
);
@@ -97,6 +144,19 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
flatFieldMetadata?.universalIdentifier;
}
+ if ('imageIdentifierFieldMetadataId' in updatedEditableObjectProperties) {
+ const { imageIdentifierFieldMetadataId } =
+ updatedEditableObjectProperties;
+
+ toFlatObjectMetadata.imageIdentifierFieldMetadataUniversalIdentifier =
+ isDefined(imageIdentifierFieldMetadataId)
+ ? findFlatEntityByIdInFlatEntityMapsOrThrow({
+ flatEntityMaps: flatFieldMetadataMaps,
+ flatEntityId: imageIdentifierFieldMetadataId,
+ }).universalIdentifier
+ : null;
+ }
+
const {
flatIndexMetadatasToUpdate,
flatViewFieldsToCreate,
diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts
index 9a17747817..5582d8df73 100644
--- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts
@@ -6,6 +6,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
@@ -22,6 +23,7 @@ export class NavigationMenuItemRecordIdentifierService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileUrlService: FileUrlService,
+ private readonly twentyConfigService: TwentyConfigService,
) {}
async resolveRecordIdentifier({
@@ -125,6 +127,9 @@ export class NavigationMenuItemRecordIdentifierService {
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
+ allowRequestsToTwentyIcons: this.twentyConfigService.get(
+ 'ALLOW_REQUESTS_TO_TWENTY_ICONS',
+ ),
signUrl: (fileId: string, fileFolder: FileFolder) =>
this.fileUrlService.signFileByIdUrl({
fileId,
diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts
index 0792ca2fa4..0940de4f71 100644
--- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts
@@ -7,12 +7,10 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.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 { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
const ID_FIELD = 'id' as const;
-const COMPANY_AVATAR_COLUMN = 'domainNamePrimaryLinkUrl' as const;
-const PERSON_AVATAR_COLUMN = 'avatarFile' as const;
-
export const getMinimalSelectForRecordIdentifier = ({
flatObjectMetadata,
flatFieldMetadataMaps,
@@ -47,15 +45,19 @@ export const getMinimalSelectForRecordIdentifier = ({
}
}
- if (flatObjectMetadata.nameSingular === 'company') {
- selectColumns.push(COMPANY_AVATAR_COLUMN);
- //TODO: Temporary solution before imageIdentifier refactor
- } else if (flatObjectMetadata.nameSingular === 'person') {
- selectColumns.push(PERSON_AVATAR_COLUMN);
- } else if (isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) {
+ if (flatObjectMetadata.nameSingular === 'workspaceMember') {
+ selectColumns.push('avatarUrl');
+
+ return selectColumns;
+ }
+
+ const imageIdentifierFieldMetadataId =
+ getEffectiveImageIdentifierFieldMetadataId(flatObjectMetadata);
+
+ if (isDefined(imageIdentifierFieldMetadataId)) {
const imageField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
- flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
+ flatEntityId: imageIdentifierFieldMetadataId,
});
if (isDefined(imageField)) {
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts
index 2d08d3a715..8b964c5a57 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts
@@ -70,7 +70,7 @@ export class UpdateObjectPayload {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
- imageIdentifierFieldMetadataId?: string;
+ imageIdentifierFieldMetadataId?: string | null;
@IsBoolean()
@IsOptional()
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts
index 19bebcec07..ab953fb890 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts
@@ -9,8 +9,8 @@ import {
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
-
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -28,12 +28,13 @@ import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadat
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
+import { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service';
-import { SearchFieldMetadataDTO } from 'src/engine/metadata-modules/search-field-metadata/dtos/search-field-metadata.dto';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
-import { resolveEffectiveEntityProperty } from 'src/engine/metadata-modules/utils/resolve-effective-entity-property.util';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
+import { SearchFieldMetadataDTO } from 'src/engine/metadata-modules/search-field-metadata/dtos/search-field-metadata.dto';
+import { resolveEffectiveEntityProperty } from 'src/engine/metadata-modules/utils/resolve-effective-entity-property.util';
@UseGuards(WorkspaceAuthGuard)
@MetadataResolver(() => ObjectMetadataDTO)
@@ -177,6 +178,13 @@ export class ObjectMetadataResolver {
);
}
+ @ResolveField(() => UUIDScalarType, { nullable: true })
+ imageIdentifierFieldMetadataId(
+ @Parent() objectMetadata: ObjectMetadataDTO,
+ ): string | null {
+ return getEffectiveImageIdentifierFieldMetadataId(objectMetadata);
+ }
+
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async createOneObject(
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type.ts
index 56d42a8be2..b1fe656876 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type.ts
@@ -6,6 +6,7 @@ export type ObjectMetadataOverrides = {
description?: string | null;
icon?: string | null;
color?: string | null;
+ imageIdentifierFieldMetadataId?: string | null;
translations?: Partial<
Record<
keyof typeof APP_LOCALES,
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util.ts
new file mode 100644
index 0000000000..6b7fadff33
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util.ts
@@ -0,0 +1,20 @@
+import { isDefined } from 'twenty-shared/utils';
+
+import { type ObjectMetadataOverrides } from 'src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type';
+
+type ImageIdentifierResolvableObjectMetadata = {
+ overrides?: ObjectMetadataOverrides | null;
+ imageIdentifierFieldMetadataId?: string | null;
+};
+
+export const getEffectiveImageIdentifierFieldMetadataId = (
+ objectMetadata: ImageIdentifierResolvableObjectMetadata,
+): string | null => {
+ const { overrides } = objectMetadata;
+
+ if (isDefined(overrides) && 'imageIdentifierFieldMetadataId' in overrides) {
+ return overrides.imageIdentifierFieldMetadataId ?? null;
+ }
+
+ return objectMetadata.imageIdentifierFieldMetadataId ?? null;
+};
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
index 9ea8e6e2fc..da6675c266 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
@@ -212,6 +212,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
shortcut: 'C',
duplicateCriteria: [['name'], ['domainNamePrimaryLinkUrl']],
labelIdentifierFieldMetadataName: 'name',
+ imageIdentifierFieldMetadataName: 'domainName',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
@@ -604,7 +605,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
['emailsPrimaryEmail'],
],
labelIdentifierFieldMetadataName: 'name',
- imageIdentifierFieldMetadataName: 'avatarUrl',
+ imageIdentifierFieldMetadataName: 'avatarFile',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
diff --git a/packages/twenty-server/src/utils/image.ts b/packages/twenty-server/src/utils/image.ts
index 3424dfced0..16ad6236d0 100644
--- a/packages/twenty-server/src/utils/image.ts
+++ b/packages/twenty-server/src/utils/image.ts
@@ -1,5 +1,8 @@
+import { detectPdf } from '@file-type/pdf';
import { type AxiosInstance } from 'axios';
import { isNonEmptyString } from '@sniptt/guards';
+import { FileTypeParser } from 'file-type';
+import { isDefined } from 'twenty-shared/utils';
export const getImageBufferFromUrl = async (
url: string,
@@ -44,3 +47,19 @@ export const getImageBufferFromUrl = async (
throw new Error(`Failed to fetch image from ${url}: ${message}`);
}
};
+
+export const fetchImageWithTypeFromUrl = async (
+ imageUrl: string,
+ axiosInstance: AxiosInstance,
+): Promise<{ buffer: Buffer; extension: string } | undefined> => {
+ const buffer = await getImageBufferFromUrl(imageUrl, axiosInstance);
+
+ const parser = new FileTypeParser({ customDetectors: [detectPdf] });
+ const type = await parser.fromBuffer(buffer);
+
+ if (!isDefined(type) || !type.mime.startsWith('image/')) {
+ return undefined;
+ }
+
+ return { buffer, extension: type.ext };
+};
diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-standard-object-image-identifier.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-standard-object-image-identifier.integration-spec.ts
new file mode 100644
index 0000000000..26d64eff02
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-standard-object-image-identifier.integration-spec.ts
@@ -0,0 +1,139 @@
+import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
+import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
+import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
+import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
+import { isDefined } from 'twenty-shared/utils';
+
+type FetchedField = {
+ id: string;
+ name: string;
+};
+
+const getCompanyImageIdentifier = async (): Promise => {
+ const { objects } = await findManyObjectMetadata({
+ expectToFail: false,
+ input: { filter: {}, paging: { first: 100 } },
+ gqlFields: `
+ id
+ nameSingular
+ imageIdentifierFieldMetadataId
+ `,
+ });
+
+ const company = objects.find((object) => object.nameSingular === 'company');
+
+ jestExpectToBeDefined(company);
+
+ return company.imageIdentifierFieldMetadataId ?? null;
+};
+
+describe('Standard object image identifier override should succeed', () => {
+ let companyObjectMetadataId: string;
+ let originalImageIdentifierFieldMetadataId: string | null;
+ let linkedinLinkFieldId: string;
+
+ beforeAll(async () => {
+ const { objects } = await findManyObjectMetadata({
+ expectToFail: false,
+ input: { filter: {}, paging: { first: 100 } },
+ gqlFields: `
+ id
+ nameSingular
+ imageIdentifierFieldMetadataId
+ `,
+ });
+
+ const company = objects.find((object) => object.nameSingular === 'company');
+
+ jestExpectToBeDefined(company);
+ companyObjectMetadataId = company.id;
+ originalImageIdentifierFieldMetadataId =
+ company.imageIdentifierFieldMetadataId ?? null;
+
+ const { fields } = await findManyFieldsMetadata({
+ expectToFail: false,
+ input: {
+ filter: { objectMetadataId: { eq: companyObjectMetadataId } },
+ paging: { first: 100 },
+ },
+ gqlFields: 'id name type',
+ });
+
+ const linkedinLinkField = fields
+ .map((edge: { node: FetchedField }) => edge.node)
+ .find((field: FetchedField) => field.name === 'linkedinLink');
+
+ jestExpectToBeDefined(linkedinLinkField);
+ linkedinLinkFieldId = linkedinLinkField.id;
+ });
+
+ afterAll(async () => {
+ await updateOneObjectMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: companyObjectMetadataId,
+ updatePayload: {
+ imageIdentifierFieldMetadataId:
+ originalImageIdentifierFieldMetadataId ?? undefined,
+ },
+ },
+ });
+ });
+
+ it('stores a non-default image identifier in overrides and resolves it', async () => {
+ const {
+ data: { updateOneObject },
+ errors,
+ } = await updateOneObjectMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: companyObjectMetadataId,
+ updatePayload: {
+ imageIdentifierFieldMetadataId: linkedinLinkFieldId,
+ },
+ },
+ gqlFields: `
+ id
+ imageIdentifierFieldMetadataId
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(updateOneObject.imageIdentifierFieldMetadataId).toBe(
+ linkedinLinkFieldId,
+ );
+ });
+
+ it('persists the overridden image identifier across refetch', async () => {
+ const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
+
+ expect(imageIdentifierFieldMetadataId).toBe(linkedinLinkFieldId);
+ });
+
+ it('respects an explicit null override instead of falling back to the base value', async () => {
+ const {
+ data: { updateOneObject },
+ errors,
+ } = await updateOneObjectMetadata({
+ expectToFail: false,
+ input: {
+ idToUpdate: companyObjectMetadataId,
+ updatePayload: {
+ imageIdentifierFieldMetadataId: null,
+ },
+ },
+ gqlFields: `
+ id
+ imageIdentifierFieldMetadataId
+ `,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(updateOneObject.imageIdentifierFieldMetadataId).toBeNull();
+
+ const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
+
+ expect(imageIdentifierFieldMetadataId).toBeNull();
+ expect(isDefined(imageIdentifierFieldMetadataId)).toBe(false);
+ });
+});
diff --git a/packages/twenty-shared/src/constants/ImageIdentifierFieldMetadataTypes.ts b/packages/twenty-shared/src/constants/ImageIdentifierFieldMetadataTypes.ts
new file mode 100644
index 0000000000..a361ab7152
--- /dev/null
+++ b/packages/twenty-shared/src/constants/ImageIdentifierFieldMetadataTypes.ts
@@ -0,0 +1,6 @@
+import { FieldMetadataType } from '@/types';
+
+export const IMAGE_IDENTIFIER_FIELD_METADATA_TYPES = [
+ FieldMetadataType.FILES,
+ FieldMetadataType.LINKS,
+];
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index f026c6f730..bd870a71a2 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -41,6 +41,7 @@ export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues'
export { GIN_COMPATIBLE_FIELD_TYPES } from './GinCompatibleFieldTypes';
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
export { IANA_TIME_ZONES } from './IanaTimeZones';
+export { IMAGE_IDENTIFIER_FIELD_METADATA_TYPES } from './ImageIdentifierFieldMetadataTypes';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
export { MAX_CUSTOM_INDEXES_PER_OBJECT } from './MaxCustomIndexesPerObject';
export { MAX_EMAIL_RECIPIENTS } from './MaxEmailRecipients';
diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/getLinkFaviconUrl.ts b/packages/twenty-shared/src/utils/image/getLinkFaviconUrl.ts
similarity index 56%
rename from packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/getLinkFaviconUrl.ts
rename to packages/twenty-shared/src/utils/image/getLinkFaviconUrl.ts
index 10f39310d3..ef1e775171 100644
--- a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/getLinkFaviconUrl.ts
+++ b/packages/twenty-shared/src/utils/image/getLinkFaviconUrl.ts
@@ -1,18 +1,26 @@
-import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
+import { getLogoUrlFromDomainName } from './getLogoUrlFromDomainName';
+// Resolves a favicon URL from an arbitrary link by extracting the hostname
+// first, so paths (e.g. https://linkedin.com/company/twenty) don't leak into
+// the twenty-icons lookup. Must stay the single source of truth for LINKS
+// image identifiers on both the front and the server.
export const getLinkFaviconUrl = (
link: string | null | undefined,
): string | undefined => {
const trimmed = (link ?? '').trim();
+
if (!trimmed) {
return undefined;
}
+
const normalized =
trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: `https://${trimmed}`;
+
try {
const hostname = new URL(normalized).hostname;
+
return getLogoUrlFromDomainName(hostname);
} catch {
return undefined;
diff --git a/packages/twenty-shared/src/utils/image/index.ts b/packages/twenty-shared/src/utils/image/index.ts
index 2cd3da401b..fbdf07401e 100644
--- a/packages/twenty-shared/src/utils/image/index.ts
+++ b/packages/twenty-shared/src/utils/image/index.ts
@@ -1,2 +1,3 @@
export * from './getImageAbsoluteURI';
+export * from './getLinkFaviconUrl';
export * from './getLogoUrlFromDomainName';
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index 406f467f62..0075a77999 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -167,6 +167,7 @@ export {
getGroupByConnectionTypename,
} from './graphql/graphql-get-typename.util';
export { getImageAbsoluteURI } from './image/getImageAbsoluteURI';
+export { getLinkFaviconUrl } from './image/getLinkFaviconUrl';
export {
sanitizeURL,
getLogoUrlFromDomainName,
@@ -231,6 +232,7 @@ export { emailSchema } from './validation/emailSchema';
export { escapeForIlike } from './validation/escapeForIlike';
export { isDefined } from './validation/isDefined';
export { isEmptyObject } from './validation/isEmptyObject';
+export { isImageIdentifierFieldMetadataType } from './validation/isImageIdentifierFieldMetadataType';
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
export type { SearchableFieldType } from './validation/isSearchableFieldType';
export { isSearchableFieldType } from './validation/isSearchableFieldType';
diff --git a/packages/twenty-shared/src/utils/validation/isImageIdentifierFieldMetadataType.ts b/packages/twenty-shared/src/utils/validation/isImageIdentifierFieldMetadataType.ts
new file mode 100644
index 0000000000..6b4a33f885
--- /dev/null
+++ b/packages/twenty-shared/src/utils/validation/isImageIdentifierFieldMetadataType.ts
@@ -0,0 +1,7 @@
+import { IMAGE_IDENTIFIER_FIELD_METADATA_TYPES } from '@/constants/ImageIdentifierFieldMetadataTypes';
+import { type FieldMetadataType } from '@/types';
+
+export const isImageIdentifierFieldMetadataType = (
+ value: FieldMetadataType,
+): value is (typeof IMAGE_IDENTIFIER_FIELD_METADATA_TYPES)[number] =>
+ IMAGE_IDENTIFIER_FIELD_METADATA_TYPES.includes(value);