Move defaultAvatarUrl on userWorkspace + migration command (#12100)
closes https://github.com/twentyhq/core-team-issues/issues/883
This commit is contained in:
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +60,9 @@ export class UserWorkspace {
|
||||
@Column()
|
||||
workspaceId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
defaultAvatarUrl: string;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@ -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],
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user