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:
@@ -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,
|
||||
|
||||
-77
@@ -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
-76
@@ -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> {
|
||||
|
||||
-49
@@ -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(
|
||||
|
||||
+14
@@ -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 {}
|
||||
+11
-34
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
-10
@@ -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 {}
|
||||
-47
@@ -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();
|
||||
});
|
||||
});
|
||||
-83
@@ -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];
|
||||
}
|
||||
}
|
||||
-214
@@ -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,
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+56
-89
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
export type CodeInterpreterFileInput = {
|
||||
filename: string;
|
||||
url: string;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type CodeInterpreterInput = {
|
||||
|
||||
+10
-44
@@ -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);
|
||||
|
||||
|
||||
+2
-9
@@ -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 {}
|
||||
|
||||
-47
@@ -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];
|
||||
}
|
||||
}
|
||||
+4
-179
@@ -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', () => {
|
||||
|
||||
+4
-81
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user