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