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:
Abdul Rahman
2026-07-15 19:15:47 +05:30
committed by GitHub
parent e31e6c7794
commit f4ff234db8
58 changed files with 1986 additions and 430 deletions
@@ -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',
@@ -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([
@@ -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,
);
});
});
@@ -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,
@@ -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,
@@ -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,
@@ -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}`);
});
});
@@ -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;
};
@@ -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({
@@ -33,6 +33,7 @@ exports[`registry-derived override property maps derives the overridable propert
"icon",
"labelPlural",
"labelSingular",
"imageIdentifierFieldMetadataId",
],
"objectPermission": [],
"pageLayout": [],
@@ -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,
@@ -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',
@@ -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,
@@ -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,
@@ -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)) {
@@ -70,7 +70,7 @@ export class UpdateObjectPayload {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
imageIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string | null;
@IsBoolean()
@IsOptional()
@@ -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(
@@ -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,
@@ -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;
};
@@ -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,