File storage cleaning (#18381)

- Remove feature flag
- Remove legacy methods in file-upload and file-service
- Migrate AI Chat to new file management

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2026-03-04 23:46:03 +01:00
committed by GitHub
parent c41a8e2b23
commit 26f0a416a1
117 changed files with 859 additions and 3239 deletions
@@ -4,9 +4,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
@@ -39,7 +39,6 @@ import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -78,7 +77,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
@Module({
imports: [
JwtModule,
FileUploadModule,
DataSourceModule,
WorkspaceDomainsModule,
TokenModule,
@@ -1,8 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -74,81 +72,6 @@ describe('FileStorageService', () => {
mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
});
describe('writeFileLegacy', () => {
it('should delegate to the current driver', async () => {
const writeParams = {
file: Buffer.from('test content'),
name: 'test.txt',
folder: 'documents',
mimeType: 'text/plain',
};
mockDriver.writeFile.mockResolvedValue(undefined);
await service.writeFileLegacy(writeParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.writeFile).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
sourceFile: writeParams.file,
mimeType: 'text/plain',
});
});
it('should handle write errors', async () => {
const writeParams = {
file: 'test content',
name: 'test.txt',
folder: 'documents',
mimeType: 'text/plain',
};
const error = new Error('Write failed');
mockDriver.writeFile.mockRejectedValue(error);
await expect(service.writeFileLegacy(writeParams)).rejects.toThrow(
'Write failed',
);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
});
});
describe('readFileLegacy', () => {
it('should delegate to the current driver', async () => {
const readParams = {
filePath: 'documents/test.txt',
};
const mockStream = new Readable();
mockDriver.readFile.mockResolvedValue(mockStream);
const result = await service.readFileLegacy(readParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.readFile).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
});
expect(result).toBe(mockStream);
});
it('should handle read errors', async () => {
const readParams = {
filePath: 'documents/test.txt',
};
const error = new Error('Read failed');
mockDriver.readFile.mockRejectedValue(error);
await expect(service.readFileLegacy(readParams)).rejects.toThrow(
'Read failed',
);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
});
});
describe('deleteLegacy', () => {
it('should delegate to the current driver with filename', async () => {
const deleteParams = {
@@ -1,12 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { mkdir, readdir, readFile, stat } from 'fs/promises';
import { basename, dirname, join } from 'path';
import { type Readable } from 'stream';
import { isObject } from '@sniptt/guards';
import { FileFolder, Sources } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { Like, Repository, type QueryRunner } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -45,23 +43,6 @@ export class FileStorageService {
).replace(/\/+/g, '/');
}
writeFileLegacy(params: {
file: string | Buffer | Uint8Array;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
const { file, name, folder, mimeType } = params;
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.writeFile({
filePath: `${folder}/${name}`,
sourceFile: file,
mimeType,
});
}
async writeFile({
sourceFile,
mimeType,
@@ -133,12 +114,6 @@ export class FileStorageService {
});
}
readFileLegacy(params: { filePath: string }): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.readFile(params);
}
readFile(params: ResourceIdentifier): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
@@ -147,56 +122,6 @@ export class FileStorageService {
return driver.readFile({ filePath: onStoragePath });
}
async writeFolderLegacy(sources: Sources, folderPath: string): Promise<void> {
for (const key of Object.keys(sources)) {
if (isObject(sources[key])) {
await this.writeFolderLegacy(sources[key], join(folderPath, key));
continue;
}
await this.writeFileLegacy({
file: sources[key],
name: key,
folder: folderPath,
mimeType: undefined,
});
}
}
async readFolderLegacy(
folderPath: string,
localTempPath?: string,
): Promise<Sources> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const tempDir = localTempPath || `/tmp/twenty-read-folder-${Date.now()}`;
await mkdir(tempDir, { recursive: true });
await driver.downloadFolder({
onStoragePath: folderPath,
localPath: tempDir,
});
return this.readLocalFolderToSources(tempDir);
}
private async readLocalFolderToSources(localPath: string): Promise<Sources> {
const sources: Sources = {};
const entries = await readdir(localPath);
for (const entry of entries) {
const entryPath = join(localPath, entry);
const stats = await stat(entryPath);
if (stats.isFile()) {
sources[entry] = await readFile(entryPath, 'utf8');
} else {
sources[entry] = await this.readLocalFolderToSources(entryPath);
}
}
return sources;
}
downloadFile(
params: ResourceIdentifier & { localPath: string },
): Promise<void> {
@@ -15,7 +15,6 @@ import {
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileByIdGuard } from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -36,7 +35,6 @@ const createMockStream = (): Readable => {
describe('FileController', () => {
let controller: FileController;
let fileService: FileService;
const mock_FilePathGuard: CanActivate = { canActivate: jest.fn(() => true) };
const mock_FileByIdGuard: CanActivate = { canActivate: jest.fn(() => true) };
const mock_PublicEndpointGuard: CanActivate = {
canActivate: jest.fn(() => true),
@@ -52,15 +50,12 @@ describe('FileController', () => {
{
provide: FileService,
useValue: {
getFileStream: jest.fn(),
getFileStreamById: jest.fn(),
getFileStreamByPath: jest.fn(),
},
},
],
})
.overrideGuard(FilePathGuard)
.useValue(mock_FilePathGuard)
.overrideGuard(FileByIdGuard)
.useValue(mock_FileByIdGuard)
.overrideGuard(PublicEndpointGuard)
@@ -79,50 +74,6 @@ describe('FileController', () => {
expect(controller).toBeDefined();
});
describe('getFile', () => {
it('should extract folder, token and filename from 3-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStream').mockResolvedValue(mockStream);
const mockRequest = {
path: '/files/attachment/test-token/test-file.csv',
workspaceId: 'workspace-id',
} as any;
const mockResponse = {} as any;
await controller.getFile(mockResponse, mockRequest);
expect(fileService.getFileStream).toHaveBeenCalledWith(
'attachment',
'test-file.csv',
'workspace-id',
);
});
it('should extract folder with size, token and filename from 4-segment path', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStream').mockResolvedValue(mockStream);
const mockRequest = {
path: '/files/profile-picture/original/test-token/avatar.jpg',
workspaceId: 'workspace-id',
} as any;
const mockResponse = {} as any;
await controller.getFile(mockResponse, mockRequest);
expect(fileService.getFileStream).toHaveBeenCalledWith(
'profile-picture/original',
'avatar.jpg',
'workspace-id',
);
});
});
describe('getFileById', () => {
it('should call fileService.getFileStreamById and pipe the result', async () => {
const mockStream = createMockStream();
@@ -23,15 +23,13 @@ import {
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import {
FileByIdGuard,
SupportedFileFolder,
} from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller()
@UseFilters(FileApiExceptionFilter)
@@ -83,47 +81,6 @@ export class FileController {
}
}
@Get('files/*path')
@UseGuards(FilePathGuard, NoPermissionGuard)
async getFile(@Res() res: Response, @Req() req: Request) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workspaceId = (req as any)?.workspaceId;
const { rawFolder, filename } = extractFileInfoFromRequest(req);
try {
const fileStream = await this.fileService.getFileStream(
rawFolder,
filename,
workspaceId,
);
fileStream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
fileStream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}
throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
@Get('file/:fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileAIChatResolver } from 'src/engine/core-modules/file/file-ai-chat/resolvers/file-ai-chat.resolver';
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [FileUrlModule, ApplicationModule, PermissionsModule],
providers: [FileAIChatService, FileAIChatResolver],
exports: [FileAIChatService],
})
export class FileAIChatModule {}
@@ -7,9 +7,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileAIChatService } from 'src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -22,46 +21,24 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@MetadataResolver()
export class FileResolver {
constructor(private readonly fileMetadataService: FileMetadataService) {}
export class FileAIChatResolver {
constructor(private readonly fileAIChatService: FileAIChatService) {}
@Mutation(() => FileDTO, {
deprecationReason: 'Use specific file service instead',
})
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async createFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
async uploadAIChatFile(
@AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<FileDTO> {
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return this.fileMetadataService.createFile({
return await this.fileAIChatService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
workspaceId,
});
}
@Mutation(() => FileDTO, {
deprecationReason: '',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async deleteFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('fileId', { type: () => UUIDScalarType }) fileId: string,
): Promise<FileDTO> {
const deletedFile = await this.fileMetadataService.deleteFileById(
fileId,
workspaceId,
);
if (!deletedFile) {
throw new Error(`File with id ${fileId} not found`);
}
return deletedFile;
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
@Injectable()
export class FileAIChatService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile({
file,
filename,
workspaceId,
}: {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDTO> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: name,
mimeType,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
};
}
}
@@ -1,10 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SignedFile')
export class SignedFileDTO {
@Field(() => String)
path: string;
@Field(() => String)
token: string;
}
@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [FileModule, PermissionsModule, SecureHttpClientModule],
providers: [FileUploadService, FileUploadResolver],
exports: [FileUploadService, FileUploadResolver],
})
export class FileUploadModule {}
@@ -1,47 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
jest.mock('graphql-upload/GraphQLUpload.mjs', () => ({
__esModule: true,
default: {},
}));
jest.mock('graphql-upload/processRequest.mjs', () => ({
__esModule: true,
FileUpload: {},
}));
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { FileUploadResolver } from './file-upload.resolver';
describe('FileUploadResolver', () => {
let resolver: FileUploadResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileUploadResolver,
{
provide: FileUploadService,
useValue: {},
},
{
provide: TwentyConfigService,
useValue: {},
},
{
provide: PermissionsService,
useValue: {},
},
],
}).compile();
resolver = module.get<FileUploadResolver>(FileUploadResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});
@@ -1,83 +0,0 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@MetadataResolver()
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => SignedFileDTO, {
deprecationReason: 'Use uploadFilesFieldFile instead',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { files } = await this.fileUploadService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload file');
}
return files[0];
}
@Mutation(() => SignedFileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadImage(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload image');
}
return files[0];
}
}
@@ -1,214 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import FileType from 'file-type';
import sharp from 'sharp';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { getCropSize, getImageBufferFromUrl } from 'src/utils/image';
export type SignedFile = { path: string; token: string };
export type SignedFilesResult = {
name: string;
mimeType: string | undefined;
files: SignedFile[];
};
@Injectable()
export class FileUploadService {
private readonly logger = new Logger(FileUploadService.name);
constructor(
private readonly fileStorage: FileStorageService,
private readonly fileService: FileService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private async _uploadFile({
file,
filename,
mimeType,
folder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
folder: string;
}) {
await this.fileStorage.writeFileLegacy({
file,
name: filename,
mimeType,
folder,
});
}
/**
* @deprecated Use uploadWorkspaceRecordFile if uploading workspace records-scoped files. Or create your dedicated upload file service.
*/
async uploadFile({
file,
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}): Promise<SignedFilesResult> {
const { ext, name } = buildFileInfo(filename);
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
await this._uploadFile({
file: sanitizeFile({ file, ext, mimeType }),
filename: name,
mimeType,
folder,
});
const signedPayload = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
return {
name,
mimeType,
files: [{ path: `${fileFolder}/${name}`, token: signedPayload }],
};
}
async uploadImageFromUrl({
imageUrl,
fileFolder,
workspaceId,
}: {
imageUrl: string;
fileFolder: FileFolder;
workspaceId: string;
}) {
const imageData = await this.fetchImageBufferFromUrl(imageUrl).catch(
(error) => {
this.logger.warn(
`Failed to fetch image from URL: ${imageUrl}${error instanceof Error ? error.message : String(error)}`,
);
return null;
},
);
if (!imageData) {
return { name: '', mimeType: undefined, files: [] };
}
return await this.uploadImage({
file: imageData.buffer,
filename: `${v4()}.${imageData.extension}`,
mimeType: imageData.mimeType,
fileFolder,
workspaceId,
});
}
private async fetchImageBufferFromUrl(imageUrl: string): Promise<{
buffer: Buffer;
extension: string;
mimeType: string;
} | null> {
const httpClient = this.secureHttpClientService.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
if (!buffer || buffer.length === 0) {
return null;
}
const type = await FileType.fromBuffer(buffer);
if (!type || !type.ext || !type.mime || !type.mime.startsWith('image/')) {
throw new Error(`Invalid image type for URL: ${imageUrl}`);
}
return { buffer, extension: type.ext, mimeType: type.mime };
}
async uploadImage({
file,
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}): Promise<SignedFilesResult> {
const { name } = buildFileInfo(filename);
const cropSizes = settings.storage.imageCropSizes[fileFolder];
if (!cropSizes) {
throw new Error(`No crop sizes found for ${fileFolder}`);
}
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
const images = await Promise.all(
sizes.map((size) =>
sharp(file).resize({
[size?.type || 'width']: size?.value ?? undefined,
}),
),
);
const files: Array<SignedFile> = [];
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
const token = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
files.push({
path: `${fileFolder}/${cropSizes[index]}/${name}`,
token,
});
return this._uploadFile({
file: buffer,
filename: `${cropSizes[index]}/${name}`,
mimeType,
folder,
});
}),
);
return {
name,
mimeType,
files,
};
}
private getWorkspaceFolderName(workspaceId: string, fileFolder: FileFolder) {
return `workspace-${workspaceId}/${fileFolder}`;
}
}
@@ -3,6 +3,7 @@ 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 { FileAIChatModule } from 'src/engine/core-modules/file/file-ai-chat/file-ai-chat.module';
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';
@@ -16,13 +17,10 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
import { FileUploadService } from './file-upload/services/file-upload.service';
import { FileUrlModule } from './file-url/file-url.module';
import { FileWorkflowModule } from './file-workflow/file-workflow.module';
import { FilesFieldModule } from './files-field/files-field.module';
import { FileByIdGuard } from './guards/file-by-id.guard';
import { FileResolver } from './resolvers/file.resolver';
import { FileMetadataService } from './services/file-metadata.service';
import { FileService } from './services/file.service';
@Module({
@@ -35,28 +33,25 @@ import { FileService } from './services/file.service';
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
FileAIChatModule,
SecureHttpClientModule,
],
providers: [
FileService,
FileMetadataService,
FileResolver,
FilePathGuard,
FileByIdGuard,
FileAttachmentListener,
FileWorkspaceMemberListener,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
FileUploadService,
],
exports: [
FileService,
FileMetadataService,
FileUrlModule,
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
FileUploadService,
FileAIChatModule,
],
controllers: [FileController],
})
@@ -11,6 +11,7 @@ export const SUPPORTED_FILE_FOLDERS = [
FileFolder.CorePicture,
FileFolder.FilesField,
FileFolder.Workflow,
FileFolder.AgentChat,
] as const;
export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number];
@@ -1,95 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from './file.service';
@Injectable()
export class FileMetadataService {
constructor(
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileService: FileService,
private readonly fileUploadService: FileUploadService,
) {}
/**
* @deprecated
*/
async createFile({
file,
filename,
mimeType,
workspaceId,
}: {
file: Buffer;
filename: string;
mimeType: string;
workspaceId: string;
}): Promise<FileDTO> {
const { files } = await this.fileUploadService.uploadFile({
file,
filename,
mimeType,
fileFolder: FileFolder.File,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload file');
}
const createdFile = this.fileRepository.create({
path: files[0].path,
size: file.length,
workspaceId,
});
const savedFile = await this.fileRepository.save(createdFile);
return savedFile;
}
/**
* @deprecated
*/
async deleteFileById(
id: string,
workspaceId: string,
): Promise<FileDTO | null> {
const file = await this.fileRepository.findOne({
where: { id, workspaceId },
});
if (!file) {
return null;
}
const { folderPath, filename } = extractFolderPathFilenameAndTypeOrThrow(
file.path,
);
try {
if (file.path) {
await this.fileService.deleteFile({
folderPath,
filename,
workspaceId,
});
}
await this.fileRepository.delete(file.id);
return file;
} catch (error) {
throw new Error(`Failed to delete file ${id}: ${error.message}`);
}
}
}
@@ -15,18 +15,11 @@ jest.mock('uuid', () => ({
describe('FileService', () => {
let service: FileService;
let fileStorageService: FileStorageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{
provide: FileStorageService,
useValue: {
copyLegacy: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {},
@@ -35,6 +28,10 @@ describe('FileService', () => {
provide: JwtWrapperService,
useValue: {},
},
{
provide: FileStorageService,
useValue: {},
},
{
provide: getRepositoryToken(FileEntity),
useValue: {},
@@ -47,35 +44,9 @@ describe('FileService', () => {
}).compile();
service = module.get<FileService>(FileService);
fileStorageService = module.get<FileStorageService>(FileStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('copyFileFromWorkspaceToWorkspace - should copy a file to a new workspace', async () => {
const result = await service.copyFileFromWorkspaceToWorkspace(
'workspaceId',
'path/to/file',
'newWorkspaceId',
);
expect(fileStorageService.copyLegacy).toHaveBeenCalledWith({
from: {
folderPath: 'workspace-workspaceId/path/to',
filename: 'file',
},
to: {
folderPath: 'workspace-newWorkspaceId/path/to',
filename: 'mocked-uuid',
},
});
expect(result).toEqual([
'workspace-newWorkspaceId',
'path/to',
'mocked-uuid',
]);
});
});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { basename, dirname, extname } from 'path';
import { type Readable } from 'stream';
import { isNonEmptyString } from '@sniptt/guards';
@@ -11,7 +10,6 @@ import {
extractFolderPathFilenameAndTypeOrThrow,
} from 'twenty-shared/utils';
import { Like, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
@@ -23,6 +21,7 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class FileService {
@@ -36,18 +35,6 @@ export class FileService {
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async getFileStream(
folderPath: string,
filename: string,
workspaceId: string,
): Promise<Readable> {
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
return await this.fileStorageService.readFileLegacy({
filePath: `${workspaceFolderPath}/${filename}`,
});
}
async getFileStreamByPath({
workspaceId,
applicationId,
@@ -106,6 +93,45 @@ export class FileService {
});
}
async getFileContentById({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<{ buffer: Buffer; mimeType: string }> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId,
},
});
const stream = await this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
const buffer = await streamToBuffer(stream);
return {
buffer,
mimeType: file.mimeType ?? 'application/octet-stream',
};
}
signFileUrl({ url, workspaceId }: { url: string; workspaceId: string }) {
if (!isNonEmptyString(url)) {
return url;
@@ -177,30 +203,4 @@ export class FileService {
folderPath: workspaceFolderPath,
});
}
async copyFileFromWorkspaceToWorkspace(
fromWorkspaceId: string,
fromPath: string,
toWorkspaceId: string,
) {
const subFolder = dirname(fromPath);
const fromWorkspaceFolderPath = `workspace-${fromWorkspaceId}`;
const toWorkspaceFolderPath = `workspace-${toWorkspaceId}`;
const fromFilename = basename(fromPath);
const toFilename = uuidV4() + extname(fromFilename);
await this.fileStorageService.copyLegacy({
from: {
folderPath: `${fromWorkspaceFolderPath}/${subFolder}`,
filename: fromFilename,
},
to: {
folderPath: `${toWorkspaceFolderPath}/${subFolder}`,
filename: toFilename,
},
});
return [toWorkspaceFolderPath, subFolder, toFilename];
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -20,6 +21,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity]),
ApplicationModule,
FeatureFlagModule,
FileModule,
JwtModule,
@@ -6,9 +6,9 @@ export const CodeInterpreterInputZodSchema = z.object({
.array(
z.object({
filename: z.string().describe('Name of the file'),
url: z
fileId: z
.string()
.describe('URL of the file to include (from user attachments)'),
.describe('ID of the uploaded file (from user attachments)'),
}),
)
.optional()
@@ -15,18 +15,23 @@ import {
type OutputFile,
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import {
type AccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { CodeInterpreterInputZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
import { TWENTY_MCP_HELPER } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const';
import { type CodeInterpreterInput } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import {
type CodeInterpreterFileInput,
type CodeInterpreterInput,
} from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
@@ -49,6 +54,8 @@ export class CodeInterpreterTool implements Tool {
private readonly codeInterpreterService: CodeInterpreterService,
private readonly fileStorageService: FileStorageService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly applicationService: ApplicationService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtWrapperService: JwtWrapperService,
@@ -94,7 +101,7 @@ export class CodeInterpreterTool implements Tool {
);
try {
const inputFiles = await this.downloadInputFiles(files);
const inputFiles = await this.downloadInputFiles(files, workspaceId);
this.logger.log(
`Executing code interpreter with ${inputFiles.length} input files`,
@@ -251,74 +258,43 @@ export class CodeInterpreterTool implements Tool {
}
private async downloadInputFiles(
files?: { filename: string; url: string }[],
files?: CodeInterpreterFileInput[],
workspaceId?: string,
): Promise<InputFile[]> {
if (!files || files.length === 0) {
return [];
}
const inputFiles: InputFile[] = [];
const serverUrl = this.twentyConfigService.get('SERVER_URL');
for (const file of files) {
try {
if (file.url.startsWith('data:')) {
const parsed = this.parseDataUrl(file.url);
if (parsed) {
inputFiles.push({
filename: file.filename,
content: parsed.content,
mimeType: parsed.mimeType,
});
}
if (!workspaceId) {
this.logger.warn(
`Cannot resolve file ${file.filename}: workspaceId is required`,
);
continue;
}
// Internal file downloads (from the server itself) use a plain client;
// external URLs go through the SSRF-protected client
const isInternalFileUrl = file.url.startsWith(serverUrl);
const httpClient = isInternalFileUrl
? this.secureHttpClientService.getInternalHttpClient()
: this.secureHttpClientService.getHttpClient();
const response = await httpClient.get(file.url, {
responseType: 'arraybuffer',
timeout: 30_000,
const { buffer, mimeType } = await this.fileService.getFileContentById({
fileId: file.fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
});
inputFiles.push({
filename: file.filename,
content: Buffer.from(response.data),
mimeType:
response.headers['content-type'] ?? 'application/octet-stream',
content: buffer,
mimeType,
});
} catch (error) {
this.logger.warn(`Failed to download file ${file.filename}`, error);
this.logger.warn(`Failed to resolve file ${file.filename}`, error);
}
}
return inputFiles;
}
private parseDataUrl(
dataUrl: string,
): { content: Buffer; mimeType: string } | null {
// Format: data:{mimeType};base64,{base64data}
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return null;
}
const [, mimeType, base64Data] = match;
return {
content: Buffer.from(base64Data, 'base64'),
mimeType,
};
}
private generateSessionToken(
workspaceId: string,
userId?: string,
@@ -349,30 +325,42 @@ export class CodeInterpreterTool implements Tool {
workspaceId: string,
executionId: string,
): Promise<CodeExecutionFile | null> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const sanitizedFilename = path.basename(file.filename);
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fileId = v4();
const resourcePath = `code-interpreter/${executionId}/${fileId}-${sanitizedFilename}`;
const savedFile = await this.fileStorageService.writeFile({
sourceFile: file.content,
mimeType: file.mimeType,
folder,
});
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
resourcePath,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.AgentChat,
});
return {
fileId: savedFile.id,
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
url: signedUrl,
mimeType: file.mimeType,
};
} catch (error) {
@@ -388,12 +376,9 @@ export class CodeInterpreterTool implements Tool {
executionId: string,
alreadyUploadedFiles: CodeExecutionFile[],
): Promise<CodeExecutionFile[]> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const outputFileUrls: CodeExecutionFile[] = [...alreadyUploadedFiles];
const uploadedFilenames = new Set(
alreadyUploadedFiles.map((f) => f.filename),
alreadyUploadedFiles.map((uploadedFile) => uploadedFile.filename),
);
for (const file of files) {
@@ -403,32 +388,14 @@ export class CodeInterpreterTool implements Tool {
continue;
}
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
mimeType: file.mimeType,
folder,
});
const uploadedFile = await this.uploadSingleFile(
file,
workspaceId,
executionId,
);
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
workspaceId,
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
outputFileUrls.push({
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
mimeType: file.mimeType,
});
} catch (error) {
this.logger.warn(
`Failed to upload output file ${file.filename}`,
error,
);
if (uploadedFile) {
outputFileUrls.push(uploadedFile);
}
}
@@ -1,6 +1,6 @@
export type CodeInterpreterFileInput = {
filename: string;
url: string;
fileId: string;
};
export type CodeInterpreterInput = {
@@ -1,32 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
isValidUuid,
} from 'twenty-shared/utils';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { WorkflowAttachment } from 'twenty-shared/workflow';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import {
EmailToolException,
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@@ -34,7 +27,6 @@ import { MessagingAccountAuthenticationService } from 'src/modules/messaging/mes
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class EmailComposerService {
private readonly logger = new Logger(EmailComposerService.name);
@@ -45,16 +37,8 @@ export class EmailComposerService {
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isOtherFileMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
}
private async getConnectedAccount(
connectedAccountId: string,
workspaceId: string,
@@ -214,29 +198,11 @@ export class EmailComposerService {
const attachments: MessageAttachment[] = [];
for (const fileMetadata of files) {
const fileEntity = fileEntityMap.get(fileMetadata.id)!;
const { folderPath, filename } = extractFolderPathFilenameAndTypeOrThrow(
fileEntity.path,
);
const isOtherFileMigrated = await this.isOtherFileMigrated(workspaceId);
let stream: Readable;
if (isOtherFileMigrated) {
stream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
});
} else {
stream = await this.fileService.getFileStream(
folderPath,
filename,
workspaceId,
);
}
const stream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
});
const buffer = await streamToBuffer(stream);
@@ -8,12 +8,10 @@ import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-acc
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
@@ -21,8 +19,8 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@@ -47,7 +45,6 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
WorkspaceDomainsModule,
TwentyORMModule,
UserRoleModule,
FileUploadModule,
FileModule,
TokenModule,
PermissionsModule,
@@ -58,10 +55,6 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
}),
],
exports: [UserWorkspaceService],
providers: [
UserWorkspaceService,
UserWorkspaceResolver,
UploadProfilePicturePermissionGuard,
],
providers: [UserWorkspaceService, UploadProfilePicturePermissionGuard],
})
export class UserWorkspaceModule {}
@@ -1,47 +0,0 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@MetadataResolver()
export class UserWorkspaceResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => SignedFileDTO)
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
async uploadWorkspaceMemberProfilePictureLegacy(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.ProfilePicture;
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload profile picture');
}
return files[0];
}
}
@@ -1,7 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { type DataSource, type Repository } from 'typeorm';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
@@ -12,10 +11,6 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import {
FileUploadService,
type SignedFilesResult,
} from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -40,8 +35,6 @@ describe('UserWorkspaceService', () => {
let approvedAccessDomainService: ApprovedAccessDomainService;
let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
let userRoleService: UserRoleService;
let fileService: FileService;
let fileUploadService: FileUploadService;
let onboardingService: OnboardingService;
beforeEach(async () => {
@@ -131,6 +124,10 @@ describe('UserWorkspaceService', () => {
provide: FileCorePictureService,
useValue: {},
},
{
provide: FileService,
useValue: {},
},
{
provide: FileStorageService,
useValue: {
@@ -141,18 +138,6 @@ describe('UserWorkspaceService', () => {
provide: LoginTokenService,
useValue: {},
},
{
provide: FileUploadService,
useValue: {
uploadImageFromUrl: jest.fn(),
},
},
{
provide: FileService,
useValue: {
copyFileFromWorkspaceToWorkspace: jest.fn(),
},
},
{
provide: OnboardingService,
useValue: {
@@ -169,7 +154,6 @@ describe('UserWorkspaceService', () => {
}).compile();
service = module.get<UserWorkspaceService>(UserWorkspaceService);
fileService = module.get<FileService>(FileService);
userWorkspaceRepository = module.get(
getRepositoryToken(UserWorkspaceEntity),
);
@@ -189,7 +173,6 @@ describe('UserWorkspaceService', () => {
} as unknown as WorkspaceRepository<UserWorkspaceEntity>);
userRoleService = module.get<UserRoleService>(UserRoleService);
fileUploadService = module.get<FileUploadService>(FileUploadService);
onboardingService = module.get<OnboardingService>(OnboardingService);
});
@@ -198,42 +181,6 @@ describe('UserWorkspaceService', () => {
});
describe('create', () => {
it("should create a user workspace with a default avatar url if it's an existing user with a user workspace having a default avatar url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue({
defaultAvatarUrl: 'path/to/file',
} as UserWorkspaceEntity);
jest
.spyOn(fileService, 'copyFileFromWorkspaceToWorkspace')
.mockResolvedValue(['', 'path/to', 'copy']);
const result = await service.create({
userId,
workspaceId,
isExistingUser: true,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: 'path/to/copy',
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's an existing user without any user workspace having a default avatar url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -265,89 +212,6 @@ describe('UserWorkspaceService', () => {
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace with a default avatar url if it's a new user with a picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest.spyOn(fileUploadService, 'uploadImageFromUrl').mockResolvedValue({
files: [{ path: 'path/to/file', token: 'token' }],
} as SignedFilesResult);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: 'picture-url',
});
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledWith({
imageUrl: 'picture-url',
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: 'path/to/file',
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it('should create a user workspace without a default avatar url if image fetch fails', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest
.spyOn(fileUploadService, 'uploadImageFromUrl')
.mockRejectedValue(
new Error(
'Failed to fetch image from https://lh3.googleusercontent.com/a/invalid: Request failed with status code 404',
),
);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: 'https://lh3.googleusercontent.com/a/invalid',
});
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledTimes(1);
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledWith({
imageUrl: 'https://lh3.googleusercontent.com/a/invalid',
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: undefined,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's a new user without a picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -373,45 +237,6 @@ describe('UserWorkspaceService', () => {
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's a new user with an empty picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as unknown as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
const uploadImageFromUrlSpy = jest
.spyOn(fileUploadService, 'uploadImageFromUrl')
.mockResolvedValue({
files: [{ path: 'path/to/file', token: 'token' }],
} as SignedFilesResult);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: '',
});
expect(uploadImageFromUrlSpy).not.toHaveBeenCalled();
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: undefined,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
});
describe('createWorkspaceMember', () => {
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm';
@@ -18,9 +18,7 @@ import {
import { type AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@@ -62,10 +60,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly userRoleService: UserRoleService,
private readonly fileCorePictureService: FileCorePictureService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly onboardingService: OnboardingService,
private readonly featureFlagService: FeatureFlagService,
) {
super(userWorkspaceRepository);
}
@@ -423,89 +419,16 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
applicationUniversalIdentifier?: string,
queryRunner?: QueryRunner,
) {
const isOtherFileMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
if (isOtherFileMigrated) {
return this.computeDefaultAvatarUrlMigrated(
userId,
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
}
return this.computeDefaultAvatarUrlLegacy(
return this.computeDefaultAvatarUrlMigrated(
userId,
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
}
private async computeDefaultAvatarUrlLegacy(
userId: string,
workspaceId: string,
isExistingUser: boolean,
pictureUrl?: string,
) {
if (isExistingUser) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId,
defaultAvatarUrl: Not(IsNull()),
},
order: {
createdAt: 'ASC',
},
});
if (!isDefined(userWorkspace?.defaultAvatarUrl)) return;
try {
const [_, subFolder, filename] =
await this.fileService.copyFileFromWorkspaceToWorkspace(
userWorkspace.workspaceId,
userWorkspace.defaultAvatarUrl,
workspaceId,
);
return `${subFolder}/${filename}`;
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
return;
}
throw error;
}
}
if (!isDefined(pictureUrl) || pictureUrl === '') return;
try {
const { files } = await this.fileUploadService.uploadImageFromUrl({
imageUrl: pictureUrl,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
if (!files.length) {
return;
}
return files[0].path;
} catch (error) {
this.logger.warn(
`Failed to upload profile picture from URL: ${pictureUrl}${error instanceof Error ? error.message : String(error)}`,
);
return;
}
}
private async computeDefaultAvatarUrlMigrated(
userId: string,
workspaceId: string,
@@ -9,12 +9,12 @@ import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
import { WorkspaceFlatWorkspaceMemberMapCacheService } from 'src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service';
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
@@ -25,7 +25,6 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@@ -44,7 +43,6 @@ import { UserService } from './services/user.service';
}),
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
DataSourceModule,
FileUploadModule,
WorkspaceModule,
OnboardingModule,
TypeOrmModule.forFeature([KeyValuePairEntity, UserWorkspaceEntity]),
@@ -6,7 +6,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
@@ -16,8 +15,8 @@ import { CustomDomainManagerModule } from 'src/engine/core-modules/domain/custom
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -26,9 +25,9 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -52,7 +51,6 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
BillingModule,
FileModule,
TokenModule,
FileUploadModule,
NestjsQueryTypeOrmModule.forFeature([
UserEntity,
WorkspaceEntity,
@@ -9,13 +9,10 @@ import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import assert from 'assert';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
@@ -30,8 +27,6 @@ import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custo
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -73,7 +68,6 @@ import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { getRequest } from 'src/utils/extract-request';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
@@ -94,7 +88,6 @@ export class WorkspaceResolver {
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly billingSubscriptionService: BillingSubscriptionService,
@@ -155,39 +148,6 @@ export class WorkspaceResolver {
return 'auto';
}
@Mutation(() => SignedFileDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
async uploadWorkspaceLogoLegacy(
@AuthWorkspace() { id }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.WorkspaceLogo;
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
workspaceId: id,
});
if (!files.length) {
throw new Error('Failed to upload workspace logo');
}
await this.workspaceService.updateOne(id, {
logo: files[0].path,
});
return files[0];
}
@ResolveField(() => [FeatureFlagDTO], { nullable: true })
async featureFlags(
@Parent() workspace: WorkspaceEntity,
@@ -330,35 +290,15 @@ export class WorkspaceResolver {
@ResolveField(() => String)
async logo(@Parent() workspace: WorkspaceEntity): Promise<string> {
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspace.id,
)
) {
if (!isDefined(workspace.logoFileId)) {
return '';
}
return this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
});
if (!isDefined(workspace.logoFileId)) {
return '';
}
if (workspace.logo) {
try {
return this.fileService.signFileUrl({
url: workspace.logo,
workspaceId: workspace.id,
});
} catch {
return workspace.logo;
}
}
return workspace.logo ?? '';
return this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
});
}
@ResolveField(() => [BillingEntitlementDTO])