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:
+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({
|
||||
|
||||
Reference in New Issue
Block a user