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
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783959648000-backfill-company-person-image-identifier-field-metadata-id.command';
import { MigratePersonAvatarUrlToAvatarFileCommand } from 'src/database/commands/upgrade-version-command/2-22/2-22-workspace-command-1783960128000-migrate-person-avatar-url-to-avatar-file.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
FilesFieldModule,
SecureHttpClientModule,
WorkspaceCacheModule,
WorkspaceMigrationModule,
WorkspaceIteratorModule,
],
providers: [
BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand,
MigratePersonAvatarUrlToAvatarFileCommand,
],
})
export class V2_22_UpgradeVersionCommandModule {}
@@ -0,0 +1,175 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const IMAGE_IDENTIFIER_BACKFILL_TARGETS = [
{
objectNameForLog: 'company',
objectUniversalIdentifier: STANDARD_OBJECTS.company.universalIdentifier,
fieldNameForLog: 'domainName',
fieldUniversalIdentifier:
STANDARD_OBJECTS.company.fields.domainName.universalIdentifier,
deprecatedFieldUniversalIdentifier: undefined,
},
{
objectNameForLog: 'person',
objectUniversalIdentifier: STANDARD_OBJECTS.person.universalIdentifier,
fieldNameForLog: 'avatarFile',
fieldUniversalIdentifier:
STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier,
deprecatedFieldUniversalIdentifier:
STANDARD_OBJECTS.person.fields.avatarUrl.universalIdentifier,
},
] as const;
@RegisteredWorkspaceCommand('2.22.0', 1783959648000)
@Command({
name: 'upgrade:2-22:backfill-company-person-image-identifier-field-metadata-id',
description:
'Backfill imageIdentifierFieldMetadataId on company (domainName) and person (avatarFile) for existing workspaces.',
})
export class BackfillCompanyPersonImageIdentifierFieldMetadataIdCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const flatObjectMetadataToUpdate: FlatObjectMetadata[] = [];
for (const target of IMAGE_IDENTIFIER_BACKFILL_TARGETS) {
const existingObject =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: target.objectUniversalIdentifier,
});
if (!isDefined(existingObject)) {
this.logger.log(
`${target.objectNameForLog} object not found for workspace ${workspaceId}, skipping`,
);
continue;
}
const currentIdentifier =
existingObject.imageIdentifierFieldMetadataUniversalIdentifier;
// Backfill when unset, or when it still points to the deprecated field
// (e.g. person.avatarUrl). Never clobber a real user customization.
const isBackfillable =
!isDefined(currentIdentifier) ||
currentIdentifier === target.deprecatedFieldUniversalIdentifier;
if (!isBackfillable) {
this.logger.log(
`imageIdentifierFieldMetadataId already set on ${target.objectNameForLog} for workspace ${workspaceId}, skipping`,
);
continue;
}
const existingField =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier: target.fieldUniversalIdentifier,
});
if (!isDefined(existingField)) {
this.logger.log(
`${target.fieldNameForLog} field not found on ${target.objectNameForLog} for workspace ${workspaceId}, skipping`,
);
continue;
}
flatObjectMetadataToUpdate.push({
...existingObject,
imageIdentifierFieldMetadataUniversalIdentifier:
target.fieldUniversalIdentifier,
});
}
if (flatObjectMetadataToUpdate.length === 0) {
this.logger.log(
`Nothing to backfill for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would backfill imageIdentifierFieldMetadataId on ${flatObjectMetadataToUpdate
.map((flatObjectMetadata) => flatObjectMetadata.nameSingular)
.join(', ')} for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: flatObjectMetadataToUpdate,
},
},
workspaceId,
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill imageIdentifierFieldMetadataId for workspace ${workspaceId}:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill imageIdentifierFieldMetadataId for workspace ${workspaceId}`,
);
}
this.logger.log(
`Backfilled imageIdentifierFieldMetadataId on ${flatObjectMetadataToUpdate
.map((flatObjectMetadata) => flatObjectMetadata.nameSingular)
.join(', ')} for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,350 @@
import { Command } from 'nest-commander';
import { isNonEmptyString } from '@sniptt/guards';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { IsNull, MoreThan, Not } from 'typeorm';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { type FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { fetchImageWithTypeFromUrl } from 'src/utils/image';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
const PERSON_BATCH_SIZE = 100;
const MIN_UUID = '00000000-0000-0000-0000-000000000000';
@RegisteredWorkspaceCommand('2.22.0', 1783960128000)
@Command({
name: 'upgrade:2-22:migrate-person-avatar-url-to-avatar-file',
description:
'Migrate legacy person.avatarUrl (external image URL) into the avatarFile FILES field for existing workspaces.',
})
export class MigratePersonAvatarUrlToAvatarFileCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly filesFieldService: FilesFieldService,
private readonly secureHttpClientService: SecureHttpClientService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
dataSource,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
if (!isDefined(dataSource)) {
this.logger.log(
`No workspace data source for workspace ${workspaceId}, skipping`,
);
return;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const personObject =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.person.universalIdentifier,
});
if (!isDefined(personObject)) {
this.logger.log(
`person object not found for workspace ${workspaceId}, skipping`,
);
return;
}
const avatarFileField =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier,
});
if (!isDefined(avatarFileField)) {
this.logger.log(
`avatarFile field not found on person for workspace ${workspaceId}, skipping`,
);
return;
}
const personRepository = dataSource.getRepository<PersonWorkspaceEntity>(
'person',
{ shouldBypassPermissionChecks: true },
);
let candidateCount = 0;
let migratedCount = 0;
let skippedCount = 0;
let failedCount = 0;
let cursor = MIN_UUID;
let persons = await this.findPersonsWithAvatarUrlBatch({
personRepository,
cursor,
});
while (persons.length > 0) {
cursor = persons[persons.length - 1].id;
for (const person of persons) {
const avatarUrl = person.avatarUrl;
if (
!isNonEmptyString(avatarUrl) ||
isNonEmptyArray(person.avatarFile)
) {
continue;
}
if (isDryRun) {
candidateCount++;
continue;
}
const result = await this.migratePersonAvatar({
personId: person.id,
avatarUrl,
workspaceId,
fieldMetadataUniversalIdentifier: avatarFileField.universalIdentifier,
personRepository,
});
if (result === 'migrated') {
migratedCount++;
} else if (result === 'skipped') {
skippedCount++;
} else {
failedCount++;
}
}
persons = await this.findPersonsWithAvatarUrlBatch({
personRepository,
cursor,
});
}
if (isDryRun) {
if (candidateCount > 0) {
this.logger.log(
`[DRY RUN] person avatarUrl -> avatarFile for workspace ${workspaceId}: ${candidateCount} candidate(s) would be attempted (download/upload not performed, so migrated/skipped/failed is unknown)`,
);
}
return;
}
if (migratedCount > 0 || skippedCount > 0 || failedCount > 0) {
this.logger.log(
`person avatarUrl -> avatarFile for workspace ${workspaceId}: ${migratedCount} migrated, ${skippedCount} skipped (unreachable/non-image), ${failedCount} failed`,
);
}
}
private async findPersonsWithAvatarUrlBatch({
personRepository,
cursor,
}: {
personRepository: WorkspaceRepository<PersonWorkspaceEntity>;
cursor: string;
}): Promise<PersonWorkspaceEntity[]> {
return personRepository.find({
select: ['id', 'avatarUrl', 'avatarFile'],
where: { id: MoreThan(cursor), avatarUrl: Not(IsNull()) },
order: { id: 'ASC' },
take: PERSON_BATCH_SIZE,
});
}
private async migratePersonAvatar({
personId,
avatarUrl,
workspaceId,
fieldMetadataUniversalIdentifier,
personRepository,
}: {
personId: string;
avatarUrl: string;
workspaceId: string;
fieldMetadataUniversalIdentifier: string;
personRepository: WorkspaceRepository<PersonWorkspaceEntity>;
}): Promise<'migrated' | 'skipped' | 'failed'> {
const imageData = await this.downloadImage({
imageUrl: avatarUrl,
personId,
workspaceId,
});
if (!isDefined(imageData)) {
return 'skipped';
}
const filename = `avatar.${imageData.extension}`;
const uploadedFile = await this.uploadAvatarFile({
buffer: imageData.buffer,
filename,
workspaceId,
fieldMetadataUniversalIdentifier,
personId,
});
if (!isDefined(uploadedFile)) {
return 'failed';
}
try {
await personRepository.update(personId, {
avatarFile: [
{
fileId: uploadedFile.id,
label: filename,
extension: imageData.extension,
},
],
});
return 'migrated';
} catch (error) {
this.logger.warn(
`Failed to attach migrated avatar for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
);
const isFileReferencedByPerson = await this.isAvatarFileReferenced({
personId,
fileId: uploadedFile.id,
workspaceId,
personRepository,
});
if (isFileReferencedByPerson) {
return 'migrated';
}
await this.safeDeleteUploadedFile(uploadedFile.id, workspaceId);
return 'failed';
}
}
private async uploadAvatarFile({
buffer,
filename,
workspaceId,
fieldMetadataUniversalIdentifier,
personId,
}: {
buffer: Buffer;
filename: string;
workspaceId: string;
fieldMetadataUniversalIdentifier: string;
personId: string;
}): Promise<FileWithSignedUrlDTO | undefined> {
try {
return await this.filesFieldService.uploadFile({
file: buffer,
filename,
workspaceId,
fieldMetadataUniversalIdentifier,
});
} catch (error) {
this.logger.warn(
`Failed to upload migrated avatar for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
);
return undefined;
}
}
private async isAvatarFileReferenced({
personId,
fileId,
workspaceId,
personRepository,
}: {
personId: string;
fileId: string;
workspaceId: string;
personRepository: WorkspaceRepository<PersonWorkspaceEntity>;
}): Promise<boolean> {
try {
const person = await personRepository.findOne({
select: ['id', 'avatarFile'],
where: { id: personId },
});
return (
person?.avatarFile?.some((file) => file.fileId === fileId) ?? false
);
} catch (error) {
this.logger.warn(
`Failed to verify avatar file reference for person ${personId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
);
return true;
}
}
private async downloadImage({
imageUrl,
personId,
workspaceId,
}: {
imageUrl: string;
personId: string;
workspaceId: string;
}): Promise<{ buffer: Buffer; extension: string } | undefined> {
const httpClient = this.secureHttpClientService.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
try {
return await fetchImageWithTypeFromUrl(imageUrl, httpClient);
} catch {
this.logger.warn(
`Failed to fetch avatar image for person ${personId} in workspace ${workspaceId}, skipping`,
);
return undefined;
}
}
private async safeDeleteUploadedFile(
fileId: string,
workspaceId: string,
): Promise<void> {
try {
await this.filesFieldService.deleteFilesFieldFile({
fileId,
workspaceId,
});
} catch (error) {
this.logger.warn(
`Failed to delete orphaned avatar file ${fileId} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -22,6 +22,7 @@ import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module';
import { V2_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module';
import { V2_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module';
import { V2_22_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-22/2-22-upgrade-version-command.module';
@Module({
imports: [
@@ -47,6 +48,7 @@ import { V2_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_19_UpgradeVersionCommandModule,
V2_20_UpgradeVersionCommandModule,
V2_21_UpgradeVersionCommandModule,
V2_22_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}