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:
+75
-1
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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 {}
|
||||
|
||||
+19
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+40
@@ -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}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -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;
|
||||
+45
@@ -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);
|
||||
});
|
||||
});
|
||||
+12
@@ -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}/`, '');
|
||||
};
|
||||
Reference in New Issue
Block a user