From 3702fefc89d93f85612b9e27a720eb2b16c8983c Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 21 May 2025 12:07:02 +0200 Subject: [PATCH] Move defaultAvatarUrl on userWorkspace + migration command (#12100) closes https://github.com/twentyhq/core-team-issues/issues/883 --- ...lt-avatar-url-to-user-workspace.command.ts | 87 +++++++++ .../0-54-upgrade-version-command.module.ts | 9 +- ...aultAvatarUrlColumnInUserWorkspaceTable.ts | 19 ++ .../auth/services/sign-in-up.service.spec.ts | 14 +- .../auth/services/sign-in-up.service.ts | 71 ++----- .../file/file-upload/file-upload.module.ts | 3 +- .../services/file-upload.service.ts | 32 ++- .../file/services/file.service.spec.ts | 35 +++- .../file/services/file.service.ts | 29 +++ .../user-workspace/user-workspace.entity.ts | 3 + .../user-workspace/user-workspace.module.ts | 4 + .../user-workspace.service.spec.ts | 184 +++++++++++++++++- .../user-workspace/user-workspace.service.ts | 88 ++++++++- 13 files changed, 500 insertions(+), 78 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483135-addDefaultAvatarUrlColumnInUserWorkspaceTable.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command.ts new file mode 100644 index 000000000..9438113de --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command.ts @@ -0,0 +1,87 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +@Command({ + name: 'upgrade:0-54:migrate-default-avatar-url-to-user-workspace', + description: 'Migrate default avatar url to user workspace', +}) +export class MigrateDefaultAvatarUrlToUserWorkspaceCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(UserWorkspace, 'core') + protected readonly userWorkspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + index, + total, + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Running command for workspace ${workspaceId} ${index + 1}/${total}`, + ); + + await this.migrateDefaultAvatarUrlToUserWorkspace({ + workspaceId, + dryRun: !!options.dryRun, + }); + } + + private async migrateDefaultAvatarUrlToUserWorkspace({ + workspaceId, + dryRun, + }: { + workspaceId: string; + dryRun: boolean; + }) { + const workspace = await this.workspaceRepository.findOneOrFail({ + where: { + id: workspaceId, + }, + relations: ['workspaceUsers', 'workspaceUsers.user'], + }); + + for (const workspaceUser of workspace.workspaceUsers) { + if (isNonEmptyString(workspaceUser.user.defaultAvatarUrl)) { + const userWorkspacesCount = await this.userWorkspaceRepository.count({ + where: { + userId: workspaceUser.user.id, + }, + }); + + if (userWorkspacesCount === 1) { + if (!dryRun) + await this.userWorkspaceRepository.update( + { + userId: workspaceUser.user.id, + workspaceId: workspace.id, + }, + { + defaultAvatarUrl: workspaceUser.user.defaultAvatarUrl, + }, + ); + + this.logger.log( + `Updated default avatar url for user ${workspaceUser.user.id} on user workspace ${workspaceUser.id}`, + ); + } + } + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts index aa1409fb3..eab514f45 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts @@ -5,8 +5,10 @@ import { CleanNotFoundFilesCommand } from 'src/database/commands/upgrade-version import { FixCreatedByDefaultValueCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-created-by-default-value.command'; import { FixStandardSelectFieldsPositionCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-fix-standard-select-fields-position.command'; import { LowercaseUserAndInvitationEmailsCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command'; +import { MigrateDefaultAvatarUrlToUserWorkspaceCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -17,7 +19,10 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor @Module({ imports: [ - TypeOrmModule.forFeature([Workspace, AppToken, User], 'core'), + TypeOrmModule.forFeature( + [Workspace, AppToken, User, UserWorkspace], + 'core', + ), TypeOrmModule.forFeature( [FieldMetadataEntity, ObjectMetadataEntity], 'metadata', @@ -32,12 +37,14 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor FixCreatedByDefaultValueCommand, CleanNotFoundFilesCommand, LowercaseUserAndInvitationEmailsCommand, + MigrateDefaultAvatarUrlToUserWorkspaceCommand, ], exports: [ FixStandardSelectFieldsPositionCommand, FixCreatedByDefaultValueCommand, CleanNotFoundFilesCommand, LowercaseUserAndInvitationEmailsCommand, + MigrateDefaultAvatarUrlToUserWorkspaceCommand, ], }) export class V0_54_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483135-addDefaultAvatarUrlColumnInUserWorkspaceTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483135-addDefaultAvatarUrlColumnInUserWorkspaceTable.ts new file mode 100644 index 000000000..77bbc3792 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1747401483135-addDefaultAvatarUrlColumnInUserWorkspaceTable.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDefaultAvatarUrlColumnInUserWorkspaceTable1747401483135 + implements MigrationInterface +{ + name = 'AddDefaultAvatarUrlColumnInUserWorkspaceTable1747401483135'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."userWorkspace" ADD "defaultAvatarUrl" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."userWorkspace" DROP COLUMN "defaultAvatarUrl"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 3a079cb71..c312b8a9e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -39,7 +39,6 @@ describe('SignInUpService', () => { let service: SignInUpService; let UserRepository: Repository; let WorkspaceRepository: Repository; - let fileUploadService: FileUploadService; let workspaceInvitationService: WorkspaceInvitationService; let userWorkspaceService: UserWorkspaceService; let twentyConfigService: TwentyConfigService; @@ -137,7 +136,6 @@ describe('SignInUpService', () => { service = module.get(SignInUpService); UserRepository = module.get(getRepositoryToken(User, 'core')); WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core')); - fileUploadService = module.get(FileUploadService); workspaceInvitationService = module.get( WorkspaceInvitationService, ); @@ -249,11 +247,6 @@ describe('SignInUpService', () => { id: 'newWorkspaceId', activationStatus: WorkspaceActivationStatus.ACTIVE, } as Workspace); - jest.spyOn(fileUploadService, 'uploadImage').mockResolvedValue({ - id: '', - mimeType: '', - paths: ['path/to/image'], - }); jest.spyOn(UserRepository, 'create').mockReturnValue({} as User); jest .spyOn(domainManagerService, 'generateSubdomain') @@ -274,7 +267,12 @@ describe('SignInUpService', () => { expect(WorkspaceRepository.save).toHaveBeenCalled(); expect(UserRepository.create).toHaveBeenCalled(); expect(UserRepository.save).toHaveBeenCalled(); - expect(fileUploadService.uploadImage).toHaveBeenCalled(); + expect(userWorkspaceService.create).toHaveBeenCalledWith({ + workspaceId: 'newWorkspaceId', + userId: 'newUserId', + isExistingUser: false, + pictureUrl: 'pictureUrl', + }); }); it('should handle signIn on workspace in pending state', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 5c63f1df7..4becf5a20 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -2,14 +2,11 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import FileType from 'file-type'; import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; -import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; - import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException, @@ -39,7 +36,6 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; -import { getImageBufferFromUrl } from 'src/utils/image'; import { isWorkEmail } from 'src/utils/is-work-email'; @Injectable() @@ -232,14 +228,10 @@ export class SignInUpService { newUserWithPicture: PartialUserWithPicture; }; - const user = await this.saveNewUser( - userData.newUserWithPicture, - params.workspace.id, - { - canAccessFullAdminPanel: false, - canImpersonate: false, - }, - ); + const user = await this.saveNewUser(userData.newUserWithPicture, { + canAccessFullAdminPanel: false, + canImpersonate: false, + }); await this.activateOnboardingForUser(user, params.workspace); @@ -284,7 +276,6 @@ export class SignInUpService { private async saveNewUser( newUserWithPicture: PartialUserWithPicture, - workspaceId: string, { canImpersonate, canAccessFullAdminPanel, @@ -293,13 +284,8 @@ export class SignInUpService { canAccessFullAdminPanel: boolean; }, ) { - const defaultAvatarUrl = await this.uploadPicture( - newUserWithPicture.picture, - workspaceId, - ); const userCreated = this.userRepository.create({ ...newUserWithPicture, - defaultAvatarUrl, canImpersonate, canAccessFullAdminPanel, }); @@ -368,15 +354,22 @@ export class SignInUpService { const workspace = await this.workspaceRepository.save(workspaceToCreate); - const user = - userData.type === 'existingUser' - ? userData.existingUser - : await this.saveNewUser(userData.newUserWithPicture, workspace.id, { - canImpersonate, - canAccessFullAdminPanel, - }); + const isExistingUser = userData.type === 'existingUser'; + const user = isExistingUser + ? userData.existingUser + : await this.saveNewUser(userData.newUserWithPicture, { + canImpersonate, + canAccessFullAdminPanel, + }); - await this.userWorkspaceService.create(user.id, workspace.id); + await this.userWorkspaceService.create({ + userId: user.id, + workspaceId: workspace.id, + isExistingUser, + pictureUrl: isExistingUser + ? undefined + : userData.newUserWithPicture.picture, + }); await this.activateOnboardingForUser(user, workspace); @@ -387,30 +380,4 @@ export class SignInUpService { return { user, workspace }; } - - async uploadPicture( - picture: string | null | undefined, - workspaceId: string, - ): Promise { - if (!picture) { - return; - } - - const buffer = await getImageBufferFromUrl( - picture, - this.httpService.axiosRef, - ); - - const type = await FileType.fromBuffer(buffer); - - const { paths } = await this.fileUploadService.uploadImage({ - file: buffer, - filename: `${v4()}.${type?.ext}`, - mimeType: type?.mime, - fileFolder: FileFolder.ProfilePicture, - workspaceId, - }); - - return paths[0]; - } } diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts index 1f18acaf4..99a908eba 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts @@ -1,3 +1,4 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver'; @@ -5,7 +6,7 @@ import { FileUploadService } from 'src/engine/core-modules/file/file-upload/serv import { FileModule } from 'src/engine/core-modules/file/file.module'; @Module({ - imports: [FileModule], + imports: [FileModule, HttpModule], providers: [FileUploadService, FileUploadResolver], exports: [FileUploadService, FileUploadResolver], }) diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index 15fadc8b1..aa114c076 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -1,22 +1,25 @@ +import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import DOMPurify from 'dompurify'; +import FileType from 'file-type'; import { JSDOM } from 'jsdom'; import sharp from 'sharp'; -import { v4 as uuidV4 } from 'uuid'; +import { v4 as uuidV4, v4 } from 'uuid'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; 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 { getCropSize } from 'src/utils/image'; +import { getCropSize, getImageBufferFromUrl } from 'src/utils/image'; @Injectable() export class FileUploadService { constructor( private readonly fileStorage: FileStorageService, private readonly fileService: FileService, + private readonly httpService: HttpService, ) {} private async _uploadFile({ @@ -93,6 +96,31 @@ export class FileUploadService { }; } + async uploadImageFromUrl({ + imageUrl, + fileFolder, + workspaceId, + }: { + imageUrl: string; + fileFolder: FileFolder; + workspaceId: string; + }) { + const buffer = await getImageBufferFromUrl( + imageUrl, + this.httpService.axiosRef, + ); + + const type = await FileType.fromBuffer(buffer); + + return await this.uploadImage({ + file: buffer, + filename: `${v4()}.${type?.ext}`, + mimeType: type?.mime, + fileFolder, + workspaceId, + }); + } + async uploadImage({ file, filename, diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts index 387b71e2a..e27d8155e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts @@ -6,8 +6,13 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { FileService } from './file.service'; +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mocked-uuid'), +})); + describe('FileService', () => { let service: FileService; + let fileStorageService: FileStorageService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -15,7 +20,9 @@ describe('FileService', () => { FileService, { provide: FileStorageService, - useValue: {}, + useValue: { + copy: jest.fn(), + }, }, { provide: TwentyConfigService, @@ -29,9 +36,35 @@ describe('FileService', () => { }).compile(); service = module.get(FileService); + fileStorageService = module.get(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.copy).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', + ]); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 5c5658460..4b91dedaf 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { basename, dirname, extname } from 'path'; import { Stream } from 'stream'; +import { v4 as uuidV4 } from 'uuid'; + import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -74,4 +77,30 @@ 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.copy({ + from: { + folderPath: `${fromWorkspaceFolderPath}/${subFolder}`, + filename: fromFilename, + }, + to: { + folderPath: `${toWorkspaceFolderPath}/${subFolder}`, + filename: toFilename, + }, + }); + + return [toWorkspaceFolderPath, subFolder, toFilename]; + } } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts index 05c39aeda..e0ead5bee 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts @@ -60,6 +60,9 @@ export class UserWorkspace { @Column() workspaceId: string; + @Column({ nullable: true }) + defaultAvatarUrl: string; + @Field() @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index 269209815..4c6f769ab 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -5,6 +5,8 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.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 { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-factor-method.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; @@ -34,6 +36,8 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works DomainManagerModule, TwentyORMModule, UserRoleModule, + FileUploadModule, + FileModule, ], services: [UserWorkspaceService], }), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index b30d0a710..fa85e564e 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -1,13 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, IsNull, Not, Repository } from 'typeorm'; + +import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -32,6 +37,8 @@ describe('UserWorkspaceService', () => { let domainManagerService: DomainManagerService; let twentyORMGlobalManager: TwentyORMGlobalManager; let userRoleService: UserRoleService; + let fileService: FileService; + let fileUploadService: FileUploadService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -46,6 +53,7 @@ describe('UserWorkspaceService', () => { countBy: jest.fn(), exists: jest.fn(), findOne: jest.fn(), + findOneOrFail: jest.fn(), }, }, { @@ -103,10 +111,29 @@ describe('UserWorkspaceService', () => { assignRoleToUserWorkspace: jest.fn(), }, }, + { + provide: FileStorageService, + useValue: { + copy: jest.fn(), + }, + }, + { + provide: FileUploadService, + useValue: { + uploadImageFromUrl: jest.fn(), + }, + }, + { + provide: FileService, + useValue: { + copyFileFromWorkspaceToWorkspace: jest.fn(), + }, + }, ], }).compile(); service = module.get(UserWorkspaceService); + fileService = module.get(FileService); userWorkspaceRepository = module.get( getRepositoryToken(UserWorkspace, 'core'), ); @@ -127,6 +154,7 @@ describe('UserWorkspaceService', () => { TwentyORMGlobalManager, ); userRoleService = module.get(UserRoleService); + fileUploadService = module.get(FileUploadService); }); it('should be defined', () => { @@ -134,7 +162,139 @@ describe('UserWorkspaceService', () => { }); describe('create', () => { - it('should create a user workspace and emit an event', async () => { + 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 UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'create') + .mockReturnValue(userWorkspace); + jest + .spyOn(userWorkspaceRepository, 'save') + .mockResolvedValue(userWorkspace); + jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue({ + defaultAvatarUrl: 'path/to/file', + } as UserWorkspace); + jest + .spyOn(fileService, 'copyFileFromWorkspaceToWorkspace') + .mockResolvedValue(['', 'path/to', 'copy']); + jest + .spyOn(workspaceEventEmitter, 'emitCustomBatchEvent') + .mockImplementation(); + + const result = await service.create({ + userId, + workspaceId, + isExistingUser: true, + }); + + expect(userWorkspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + userId, + defaultAvatarUrl: Not(IsNull()), + }, + order: { + createdAt: 'ASC', + }, + }); + + expect(userWorkspaceRepository.create).toHaveBeenCalledWith({ + userId, + workspaceId, + defaultAvatarUrl: 'path/to/copy', + }); + + expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace); + expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( + USER_SIGNUP_EVENT_NAME, + [{ userId }], + workspaceId, + ); + 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'; + const userWorkspace = { userId, workspaceId } as UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'create') + .mockReturnValue(userWorkspace); + jest + .spyOn(userWorkspaceRepository, 'save') + .mockResolvedValue(userWorkspace); + + jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue(null); + + jest + .spyOn(workspaceEventEmitter, 'emitCustomBatchEvent') + .mockImplementation(); + + const result = await service.create({ + userId, + workspaceId, + isExistingUser: true, + }); + + expect(userWorkspaceRepository.create).toHaveBeenCalledWith({ + userId, + workspaceId, + defaultAvatarUrl: undefined, + }); + expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace); + expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( + USER_SIGNUP_EVENT_NAME, + [{ userId }], + workspaceId, + ); + 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 UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'create') + .mockReturnValue(userWorkspace); + jest + .spyOn(userWorkspaceRepository, 'save') + .mockResolvedValue(userWorkspace); + + jest + .spyOn(workspaceEventEmitter, 'emitCustomBatchEvent') + .mockImplementation(); + jest.spyOn(fileUploadService, 'uploadImageFromUrl').mockResolvedValue({ + paths: ['path/to/file'], + } as any); + + 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(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( + USER_SIGNUP_EVENT_NAME, + [{ userId }], + workspaceId, + ); + 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'; const userWorkspace = { userId, workspaceId } as UserWorkspace; @@ -149,11 +309,17 @@ describe('UserWorkspaceService', () => { .spyOn(workspaceEventEmitter, 'emitCustomBatchEvent') .mockImplementation(); - const result = await service.create(userId, workspaceId); + const result = await service.create({ + userId, + workspaceId, + isExistingUser: false, + pictureUrl: undefined, + }); expect(userWorkspaceRepository.create).toHaveBeenCalledWith({ userId, workspaceId, + defaultAvatarUrl: undefined, }); expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace); expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( @@ -214,6 +380,10 @@ describe('UserWorkspaceService', () => { .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') .mockResolvedValue(workspaceMemberRepository as any); + jest.spyOn(userWorkspaceRepository, 'findOneOrFail').mockResolvedValue({ + defaultAvatarUrl: 'userWorkspace-avatar-url', + } as UserWorkspace); + await service.createWorkspaceMember(workspaceId, user); expect(workspaceMemberRepository.insert).toHaveBeenCalledWith({ @@ -225,7 +395,7 @@ describe('UserWorkspaceService', () => { userId: user.id, userEmail: user.email, locale: 'en', - avatarUrl: 'avatar-url', + avatarUrl: 'userWorkspace-avatar-url', }); expect(objectMetadataRepository.findOneOrFail).toHaveBeenCalledWith({ where: { @@ -284,7 +454,11 @@ describe('UserWorkspaceService', () => { user.id, workspace.id, ); - expect(service.create).toHaveBeenCalledWith(user.id, workspace.id); + expect(service.create).toHaveBeenCalledWith({ + workspaceId: workspace.id, + userId: user.id, + isExistingUser: true, + }); expect(service.createWorkspaceMember).toHaveBeenCalledWith( workspace.id, user, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index f88c56bbd..6bd4099b5 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -4,9 +4,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; + +import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; import { @@ -15,13 +16,14 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; -import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsException, @@ -42,21 +44,40 @@ export class UserWorkspaceService extends TypeOrmQueryService { private readonly userRepository: Repository, @InjectRepository(ObjectMetadataEntity, 'metadata') private readonly objectMetadataRepository: Repository, - private readonly dataSourceService: DataSourceService, - private readonly typeORMService: TypeORMService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly domainManagerService: DomainManagerService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly userRoleService: UserRoleService, + private readonly fileUploadService: FileUploadService, + private readonly fileService: FileService, ) { super(userWorkspaceRepository); } - async create(userId: string, workspaceId: string): Promise { + async create({ + userId, + workspaceId, + isExistingUser, + pictureUrl, + }: { + userId: string; + workspaceId: string; + isExistingUser: boolean; + pictureUrl?: string; + }): Promise { + const defaultAvatarUrl = await this.computeDefaultAvatarUrl( + userId, + workspaceId, + isExistingUser, + pictureUrl, + ); + const userWorkspace = this.userWorkspaceRepository.create({ userId, workspaceId, + defaultAvatarUrl, }); this.workspaceEventEmitter.emitCustomBatchEvent( @@ -78,6 +99,13 @@ export class UserWorkspaceService extends TypeOrmQueryService { }, ); + const userWorkspace = await this.userWorkspaceRepository.findOneOrFail({ + where: { + userId: user.id, + workspaceId, + }, + }); + await workspaceMemberRepository.insert({ name: { firstName: user.firstName, @@ -86,7 +114,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { colorScheme: 'System', userId: user.id, userEmail: user.email, - avatarUrl: user.defaultAvatarUrl ?? '', + avatarUrl: userWorkspace.defaultAvatarUrl ?? '', locale: (user.locale ?? SOURCE_LOCALE) as keyof typeof APP_LOCALES, }); @@ -133,7 +161,11 @@ export class UserWorkspaceService extends TypeOrmQueryService { ); if (!userWorkspace) { - userWorkspace = await this.create(user.id, workspace.id); + userWorkspace = await this.create({ + userId: user.id, + workspaceId: workspace.id, + isExistingUser: true, + }); await this.createWorkspaceMember(workspace.id, user); @@ -307,4 +339,44 @@ export class UserWorkspaceService extends TypeOrmQueryService { return workspaceMember; } + + private async computeDefaultAvatarUrl( + 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; + + const [_, subFolder, filename] = + await this.fileService.copyFileFromWorkspaceToWorkspace( + userWorkspace.workspaceId, + userWorkspace.defaultAvatarUrl, + workspaceId, + ); + + return `${subFolder}/${filename}`; + } + + if (!isDefined(pictureUrl)) return; + + const { paths } = await this.fileUploadService.uploadImageFromUrl({ + imageUrl: pictureUrl, + fileFolder: FileFolder.ProfilePicture, + workspaceId, + }); + + return paths[0]; + } }