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. Review in cubic --- .../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);