Move defaultAvatarUrl on userWorkspace + migration command (#12100)

closes https://github.com/twentyhq/core-team-issues/issues/883
This commit is contained in:
Etienne
2025-05-21 12:07:02 +02:00
committed by GitHub
parent 8e2d0139ed
commit 3702fefc89
13 changed files with 500 additions and 78 deletions

View File

@ -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<Workspace>,
@InjectRepository(UserWorkspace, 'core')
protected readonly userWorkspaceRepository: Repository<UserWorkspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
index,
total,
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
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}`,
);
}
}
}
}
}

View File

@ -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 {}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDefaultAvatarUrlColumnInUserWorkspaceTable1747401483135
implements MigrationInterface
{
name = 'AddDefaultAvatarUrlColumnInUserWorkspaceTable1747401483135';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD "defaultAvatarUrl" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP COLUMN "defaultAvatarUrl"`,
);
}
}

View File

@ -39,7 +39,6 @@ describe('SignInUpService', () => {
let service: SignInUpService;
let UserRepository: Repository<User>;
let WorkspaceRepository: Repository<Workspace>;
let fileUploadService: FileUploadService;
let workspaceInvitationService: WorkspaceInvitationService;
let userWorkspaceService: UserWorkspaceService;
let twentyConfigService: TwentyConfigService;
@ -137,7 +136,6 @@ describe('SignInUpService', () => {
service = module.get<SignInUpService>(SignInUpService);
UserRepository = module.get(getRepositoryToken(User, 'core'));
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
fileUploadService = module.get<FileUploadService>(FileUploadService);
workspaceInvitationService = module.get<WorkspaceInvitationService>(
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 () => {

View File

@ -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<string | undefined> {
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];
}
}

View File

@ -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],
})

View File

@ -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,

View File

@ -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>(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.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',
]);
});
});

View File

@ -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];
}
}

View File

@ -60,6 +60,9 @@ export class UserWorkspace {
@Column()
workspaceId: string;
@Column({ nullable: true })
defaultAvatarUrl: string;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

View File

@ -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],
}),

View File

@ -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>(UserWorkspaceService);
fileService = module.get<FileService>(FileService);
userWorkspaceRepository = module.get(
getRepositoryToken(UserWorkspace, 'core'),
);
@ -127,6 +154,7 @@ describe('UserWorkspaceService', () => {
TwentyORMGlobalManager,
);
userRoleService = module.get<UserRoleService>(UserRoleService);
fileUploadService = module.get<FileUploadService>(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,

View File

@ -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<UserWorkspace> {
private readonly userRepository: Repository<User>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
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<UserWorkspace> {
async create({
userId,
workspaceId,
isExistingUser,
pictureUrl,
}: {
userId: string;
workspaceId: string;
isExistingUser: boolean;
pictureUrl?: string;
}): Promise<UserWorkspace> {
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<UserWorkspace> {
},
);
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<UserWorkspace> {
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<UserWorkspace> {
);
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<UserWorkspace> {
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];
}
}