feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## 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. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-2
@@ -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<string>();
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ describe('useGetObjectRecordIdentifierByNameSingular', () => {
|
||||
rerender({
|
||||
record: {
|
||||
id: 'recordId',
|
||||
domainName: 'https://cool-company.com',
|
||||
domainName: { primaryLinkUrl: 'https://cool-company.com' },
|
||||
},
|
||||
objectNameSingular: 'company',
|
||||
});
|
||||
|
||||
+90
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+6
-8
@@ -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,
|
||||
);
|
||||
|
||||
+37
-5
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
+4
-5
@@ -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,
|
||||
|
||||
@@ -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<FieldMetadataItem, 'id' | 'name'>;
|
||||
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
|
||||
);
|
||||
};
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
|
||||
{
|
||||
"accountOwner": {
|
||||
"avatarUrl": true,
|
||||
"id": true,
|
||||
"name": true,
|
||||
},
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ export const MultipleRecordPickerMenuItemContent = ({
|
||||
placeholderColorSeed={searchRecord.recordId}
|
||||
placeholder={displayText}
|
||||
size="md"
|
||||
type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
|
||||
type={getAvatarType(objectMetadataItem)}
|
||||
/>
|
||||
}
|
||||
text={displayText}
|
||||
|
||||
+19
-6
@@ -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={
|
||||
|
||||
+6
-8
@@ -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 = ({
|
||||
</FieldContext.Provider>
|
||||
}
|
||||
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
|
||||
onUploadPicture={
|
||||
objectNameSingular === CoreObjectNameSingular.Person
|
||||
? onUploadPicture
|
||||
: undefined
|
||||
}
|
||||
onUploadPicture={onUploadPicture}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
-59
@@ -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 };
|
||||
};
|
||||
+82
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+49
-24
@@ -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<SelectOption<string | null>>((fieldMetadataItem) => ({
|
||||
Icon: getIcon(fieldMetadataItem.icon),
|
||||
label: fieldMetadataItem.label,
|
||||
value: fieldMetadataItem.id,
|
||||
})),
|
||||
[getIcon, objectMetadataItem],
|
||||
);
|
||||
const imageIdentifierFieldOptions: SelectOption<string | null>[] = [];
|
||||
|
||||
const mapFieldToSelectOption = (
|
||||
fieldMetadataItem: FieldMetadataItem,
|
||||
): SelectOption<string | null> => ({
|
||||
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<string | null> = {
|
||||
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 }) => (
|
||||
<Controller
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
@@ -134,7 +159,7 @@ export const SettingsDataModelObjectIdentifiersForm = ({
|
||||
options={options}
|
||||
value={value}
|
||||
withSearchInput={label === t`Record label`}
|
||||
disabled={!isCustomObject || readonly}
|
||||
disabled={disabled}
|
||||
callToActionButton={
|
||||
label === t`Record label`
|
||||
? {
|
||||
@@ -146,8 +171,8 @@ export const SettingsDataModelObjectIdentifiersForm = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
formConfig.handleSubmit(handleSave)();
|
||||
}}
|
||||
/>
|
||||
|
||||
+30
-20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+70
@@ -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",
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+26
@@ -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 {}
|
||||
+175
@@ -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<void> {
|
||||
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<FlatObjectMetadata>({
|
||||
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<FlatFieldMetadata>({
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+350
@@ -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<void> {
|
||||
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<FlatObjectMetadata>({
|
||||
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<FlatFieldMetadata>({
|
||||
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<PersonWorkspaceEntity>(
|
||||
'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<PersonWorkspaceEntity>;
|
||||
cursor: string;
|
||||
}): Promise<PersonWorkspaceEntity[]> {
|
||||
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<PersonWorkspaceEntity>;
|
||||
}): 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<FileWithSignedUrlDTO | undefined> {
|
||||
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<PersonWorkspaceEntity>;
|
||||
}): Promise<boolean> {
|
||||
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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {}
|
||||
|
||||
+18
-3
@@ -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<FlatFieldMetadata> = {
|
||||
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<FlatFieldMetadata> = {
|
||||
},
|
||||
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',
|
||||
|
||||
+2
@@ -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([
|
||||
|
||||
+119
@@ -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<CalendarEventWorkspaceEntity> = {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+83
-64
@@ -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<UserWorkspaceEntity>,
|
||||
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,
|
||||
|
||||
+2
-13
@@ -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)}`,
|
||||
|
||||
@@ -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<string | null> {
|
||||
const firstFileId = filesFieldValue?.[0]?.fileId;
|
||||
|
||||
if (!isDefined(firstFileId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.signFileByIdUrl({
|
||||
fileId: firstFileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
});
|
||||
}
|
||||
|
||||
async signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
|
||||
+42
-26
@@ -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<ConnectedAccountEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+232
@@ -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<FlatFieldMetadata> => {
|
||||
const byUniversalIdentifier: Record<string, FlatFieldMetadata> = {};
|
||||
const universalIdentifierById: Record<string, string> = {};
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
+40
-36
@@ -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<string, unknown>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
signUrl?: (
|
||||
fileId: string,
|
||||
fileFolder: FileFolder,
|
||||
@@ -23,35 +24,16 @@ export const getRecordImageIdentifier = async ({
|
||||
record,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
allowRequestsToTwentyIcons,
|
||||
signUrl,
|
||||
}: GetRecordImageIdentifierOptions): Promise<string | null> => {
|
||||
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;
|
||||
};
|
||||
|
||||
+10
-10
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<FlatFieldMetadata>,
|
||||
) {
|
||||
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<FlatFieldMetadata>,
|
||||
): 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<FlatFieldMetadata>,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
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({
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ exports[`registry-derived override property maps derives the overridable propert
|
||||
"icon",
|
||||
"labelPlural",
|
||||
"labelSingular",
|
||||
"imageIdentifierFieldMetadataId",
|
||||
],
|
||||
"objectPermission": [],
|
||||
"pageLayout": [],
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+60
@@ -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,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+12
-10
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export class UpdateObjectPayload {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
+11
-3
@@ -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(
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+20
@@ -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;
|
||||
};
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
+139
@@ -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<string | null> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FieldMetadataType } from '@/types';
|
||||
|
||||
export const IMAGE_IDENTIFIER_FIELD_METADATA_TYPES = [
|
||||
FieldMetadataType.FILES,
|
||||
FieldMetadataType.LINKS,
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
+9
-1
@@ -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;
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './getImageAbsoluteURI';
|
||||
export * from './getLinkFaviconUrl';
|
||||
export * from './getLogoUrlFromDomainName';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user