Files v2 - FILES field update/create in common API (#17400)

## FILES Field update interface

```
mutation {
  updateCompany(
    id: "company-uuid"
    data: {
      filesfield: [
          { fileId: "new-file-uuid", label: "new-document.pdf" }
        ]
    }
  ) {
    id
    filesfield {
      fileId
      label
      extension
    }
  }
}
```
This commit is contained in:
Etienne
2026-01-29 14:33:26 +01:00
committed by GitHub
parent 3dfb5ffc02
commit 09de762285
43 changed files with 1929 additions and 236 deletions
@@ -4,11 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { type Readable } from 'stream';
import { FileFolder, Sources } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { Like, Repository } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types';
@Injectable()
//TODO: Implement storage driver interface when removing v1
@@ -50,6 +51,7 @@ export class FileStorageService {
applicationUniversalIdentifier,
workspaceId,
fileId,
settings,
}: {
sourceFile: string | Buffer | Uint8Array;
destinationPath: string;
@@ -58,6 +60,7 @@ export class FileStorageService {
applicationUniversalIdentifier: string;
workspaceId: string;
fileId?: string;
settings: FileSettings;
}): Promise<FileEntity> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
@@ -85,6 +88,7 @@ export class FileStorageService {
typeof sourceFile === 'string'
? Buffer.byteLength(sourceFile)
: sourceFile.length,
settings,
});
return fileEntity;
@@ -136,6 +140,32 @@ export class FileStorageService {
return driver.delete(params);
}
async deleteByFileId({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<void> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});
const driver = this.fileStorageDriverFactory.getCurrentDriver();
await driver.delete({
folderPath: `${file.workspaceId}/${file.applicationId}`,
filename: file.path,
});
await this.fileRepository.delete(fileId);
}
move(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
@@ -154,6 +184,50 @@ export class FileStorageService {
return driver.copy(params);
}
async moveFile({
from,
to,
workspaceId,
}: {
from: {
applicationId: string;
fileFolder: FileFolder;
destinationPath: string;
};
to: {
applicationId: string;
fileFolder: FileFolder;
destinationPath: string;
};
workspaceId: string;
}): Promise<void> {
const file = await this.fileRepository.findOneOrFail({
where: {
workspaceId,
applicationId: from.applicationId,
path: `${from.fileFolder}/${from.destinationPath}`,
},
});
const driver = this.fileStorageDriverFactory.getCurrentDriver();
await driver.move({
from: {
folderPath: `${file.workspaceId}/${from.applicationId}/${from.fileFolder}`,
filename: from.destinationPath,
},
to: {
folderPath: `${file.workspaceId}/${to.applicationId}/${to.fileFolder}`,
filename: to.destinationPath,
},
});
await this.fileRepository.update(file.id, {
applicationId: to.applicationId,
path: `${to.fileFolder}/${to.destinationPath}`,
});
}
download(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
@@ -12,6 +12,7 @@ import {
} from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity('file')
@@ -46,4 +47,7 @@ export class FileEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, default: false })
isStaticAsset: boolean;
@Column({ nullable: true, type: 'jsonb' })
settings: FileSettings | null;
}
@@ -256,6 +256,10 @@ export class FileUploadService {
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
}
}
@@ -3,6 +3,9 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FilesFieldDeletionJob } from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job';
import { FilesFieldDeletionListener } from 'src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
@@ -10,11 +13,13 @@ import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/f
import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileUploadService } from './file-upload/services/file-upload.service';
import { FilesFieldService } from './files-field/files-field.service';
import { FileResolver } from './resolvers/file.resolver';
import { FileMetadataService } from './services/file-metadata.service';
import { FileService } from './services/file.service';
@@ -25,19 +30,24 @@ import { FileService } from './services/file.service';
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
HttpModule,
PermissionsModule,
FileStorageModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [
FileService,
FileMetadataService,
FilesFieldService,
FileResolver,
FilePathGuard,
FileAttachmentListener,
FileWorkspaceMemberListener,
FilesFieldDeletionListener,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
FilesFieldDeletionJob,
FileUploadService,
],
exports: [FileService, FileMetadataService],
exports: [FileService, FileMetadataService, FilesFieldService],
controllers: [FileController],
})
export class FileModule {}
@@ -0,0 +1,19 @@
import { type MessageDescriptor } from '@lingui/core';
import { CustomException } from 'src/utils/custom-exception';
export enum FilesFieldExceptionCode {
FILE_DELETION_FAILED = 'FILE_DELETION_FAILED',
}
export class FilesFieldException extends CustomException<FilesFieldExceptionCode> {
constructor(
message: string,
code: FilesFieldExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor },
) {
super(message, code, {
userFriendlyMessage,
});
}
}
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { FileFolder } from 'twenty-shared/types';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import {
FilesFieldException,
FilesFieldExceptionCode,
} from './files-field.exception';
@Injectable()
export class FilesFieldService {
constructor(private readonly fileStorageService: FileStorageService) {}
async deleteFilesFieldFile({
fileId,
workspaceId,
}: {
fileId: string;
workspaceId: string;
}): Promise<void> {
try {
await this.fileStorageService.deleteByFileId({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
});
} catch (error) {
throw new FilesFieldException(
`Failed to delete file ${fileId}: ${error.message}`,
FilesFieldExceptionCode.FILE_DELETION_FAILED,
{
userFriendlyMessage: msg`Failed to delete file ${fileId}`,
},
);
}
}
}
@@ -0,0 +1,40 @@
import { Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
export type FilesFieldDeletionJobData = {
workspaceId: string;
fileIds: string[];
};
@Processor(MessageQueue.deleteCascadeQueue)
export class FilesFieldDeletionJob {
private readonly logger = new Logger(FilesFieldDeletionJob.name);
constructor(private readonly filesFieldService: FilesFieldService) {}
@Process(FilesFieldDeletionJob.name)
async handle(data: FilesFieldDeletionJobData): Promise<void> {
const { workspaceId, fileIds } = data;
if (!isDefined(fileIds) || fileIds.length === 0) {
return;
}
for (const fileId of fileIds) {
try {
await this.filesFieldService.deleteFilesFieldFile({
fileId,
workspaceId,
});
} catch {
this.logger.log(`Failed to delete file ${fileId}`);
}
}
}
}
@@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { type ObjectRecordDestroyEvent } from 'twenty-shared/database-events';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import {
FilesFieldDeletionJob,
FilesFieldDeletionJobData,
} from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
type FileItem = {
fileId: string;
label: string;
extension?: string;
};
@Injectable()
export class FilesFieldDeletionListener {
constructor(
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@OnDatabaseBatchEvent('*', DatabaseEventAction.DESTROYED)
async handleDestroyedEvent(
payload: WorkspaceEventBatch<ObjectRecordDestroyEvent>,
) {
const workspaceId = payload.workspaceId;
const objectMetadata = payload.objectMetadata;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const objectId = idByNameSingular[objectMetadata.nameSingular];
if (!isDefined(objectId)) {
return;
}
const flatObjectMetadata = flatObjectMetadataMaps.byId[objectId];
if (!isDefined(flatObjectMetadata)) {
return;
}
const objectFields = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
);
const filesFields = objectFields.filter(
(field) => field.type === FieldMetadataType.FILES,
);
if (filesFields.length === 0) {
return;
}
const fileIds = new Set<string>();
for (const event of payload.events) {
const recordBefore = event.properties.before as Record<string, unknown>;
for (const filesField of filesFields) {
const filesValue = recordBefore[filesField.name];
if (!isDefined(filesValue)) {
continue;
}
const fileItems = filesValue as FileItem[];
for (const fileItem of fileItems) {
if (!isDefined(fileItem.fileId)) {
continue;
}
fileIds.add(fileItem.fileId);
}
}
}
if (fileIds.size > 0) {
await this.messageQueueService.add<FilesFieldDeletionJobData>(
FilesFieldDeletionJob.name,
{
workspaceId,
fileIds: Array.from(fileIds),
},
);
}
}
}
@@ -51,9 +51,6 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.FilesField]: {
ignoreExpirationToken: false,
},
[FileFolder.TemporaryFilesField]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -0,0 +1,6 @@
export type FileFieldSettings = {
isTemporaryFile: boolean;
toDelete: boolean;
};
export type FileSettings = FileFieldSettings;
@@ -0,0 +1,45 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
describe('removeFileFolderFromFileEntityPath', () => {
it('should remove file folder prefix from path', () => {
expect(
removeFileFolderFromFileEntityPath(`${FileFolder.Attachment}/file.txt`),
).toBe('file.txt');
});
it('should handle nested paths correctly', () => {
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.Attachment}/subfolder/file.txt`,
),
).toBe('subfolder/file.txt');
});
it('should work with different valid file folders', () => {
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.ProfilePicture}/avatar.png`,
),
).toBe('avatar.png');
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.WorkspaceLogo}/logo.svg`,
),
).toBe('logo.svg');
expect(
removeFileFolderFromFileEntityPath(`${FileFolder.FilesField}/doc.pdf`),
).toBe('doc.pdf');
});
it('should throw BadRequestException for invalid file folder', () => {
expect(() =>
removeFileFolderFromFileEntityPath('invalid-folder/file.txt'),
).toThrow(BadRequestException);
});
});
@@ -0,0 +1,12 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
export const removeFileFolderFromFileEntityPath = (path: string): string => {
const [fileFolder, ..._path] = path.split('/');
if (!Object.values(FileFolder).includes(fileFolder as FileFolder))
throw new BadRequestException(`File folder ${fileFolder} is not allowed`);
return path.replace(`${fileFolder}/`, '');
};