feat: user can have multiple workspaces (backend) (#4036)

* create user-workspace mapping

* user-workspace service and integration

* invite condition on sign-up/sign-in

* save/update defaultWorkspace on signup

* add unique decorator on user-workspace entity

* remove resolver permissions

* Fixes

* Fixes

* Fix tests

* Fixes

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Aditya Pimpalkar 2024-02-25 09:58:14 +00:00 committed by GitHub
parent 52b33b5450
commit b67957bf94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 226 additions and 10 deletions

View File

@ -72,14 +72,24 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
},
onCompleted: (data) => {
if (data?.checkUserExists.exists) {
setSignInUpMode(SignInUpMode.SignIn);
isMatchingLocation(AppPath.Invite)
? setSignInUpMode(SignInUpMode.Invite)
: setSignInUpMode(SignInUpMode.SignIn);
} else {
setSignInUpMode(SignInUpMode.SignUp);
isMatchingLocation(AppPath.Invite)
? setSignInUpMode(SignInUpMode.Invite)
: setSignInUpMode(SignInUpMode.SignUp);
}
setSignInUpStep(SignInUpStep.Password);
},
});
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
}, [
isMatchingLocation,
setSignInUpStep,
checkUserExistsQuery,
form,
setSignInUpMode,
]);
const submitCredentials: SubmitHandler<Form> = useCallback(
async (data) => {

View File

@ -18,6 +18,7 @@ import { GoogleGmailAuthController } from 'src/core/auth/controllers/google-gmai
import { VerifyAuthController } from 'src/core/auth/controllers/verify-auth.controller';
import { TokenService } from 'src/core/auth/services/token.service';
import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service';
import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
import { AuthResolver } from './auth.resolver';
@ -45,6 +46,7 @@ const jwtModule = JwtModule.registerAsync({
TypeORMModule,
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
HttpModule,
UserWorkspaceModule,
],
controllers: [
GoogleAuthController,

View File

@ -91,6 +91,7 @@ export class AuthResolver {
@Mutation(() => LoginToken)
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
const user = await this.authService.signUp(signUpInput);
const loginToken = await this.tokenService.generateLoginToken(user.email);
return { loginToken };
@ -120,6 +121,8 @@ export class AuthResolver {
verifyInput.loginToken,
);
assert(email, 'Invalid token', ForbiddenException);
const result = await this.authService.verify(email);
return result;

View File

@ -9,6 +9,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
import { User } from 'src/core/user/user.entity';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { EmailService } from 'src/integrations/email/email.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { AuthService } from './auth.service';
import { TokenService } from './token.service';
@ -28,6 +29,10 @@ describe('AuthService', () => {
provide: UserService,
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},
},
{
provide: WorkspaceManagerService,
useValue: {},

View File

@ -34,6 +34,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
import { EmailService } from 'src/integrations/email/email.service';
import { UpdatePassword } from 'src/core/auth/dto/update-password.entity';
import { getImageBufferFromUrl } from 'src/utils/image';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { TokenService } from './token.service';
@ -54,6 +55,7 @@ export class AuthService {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
@ -95,11 +97,16 @@ export class AuthService {
if (!firstName) firstName = '';
if (!lastName) lastName = '';
const existingUser = await this.userRepository.findOneBy({
const existingUser = await this.userRepository.findOne({
where: {
email: email,
},
relations: ['defaultWorkspace'],
});
if (existingUser && !workspaceInviteHash) {
assert(!existingUser, 'This user already exists', ForbiddenException);
}
if (password) {
const isPasswordValid = PASSWORD_REGEX.test(password);
@ -157,6 +164,31 @@ export class AuthService {
imagePath = paths[0];
}
if (existingUser && workspaceInviteHash) {
const userWorkspaceExists =
await this.userWorkspaceService.checkUserWorkspaceExists(
existingUser.id,
workspace.id,
);
if (!userWorkspaceExists) {
await this.userWorkspaceService.create(existingUser.id, workspace.id);
await this.userWorkspaceService.createWorkspaceMember(
workspace.id,
existingUser,
);
}
const updatedUser = await this.userRepository.save({
id: existingUser.id,
defaultWorkspace: workspace,
updatedAt: new Date().toISOString(),
});
return Object.assign(existingUser, updatedUser);
}
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
@ -169,9 +201,8 @@ export class AuthService {
const user = await this.userRepository.save(userToCreate);
if (workspaceInviteHash) {
await this.userService.createWorkspaceMember(user);
}
await this.userWorkspaceService.create(user.id, workspace.id);
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
return user;
}

View File

@ -0,0 +1,48 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from 'src/core/user/user.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
@Entity({ name: 'userWorkspace', schema: 'core' })
@ObjectType('UserWorkspace')
@Unique('IndexOnUserIdAndWorkspaceIdUnique', ['userId', 'workspaceId'])
export class UserWorkspace {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@Column()
workspaceId: string;
@Field()
@CreateDateColumn({ type: 'timestamp with time zone' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
@Field()
@Column('timestamp with time zone')
deletedAt: Date;
}

View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'),
TypeORMModule,
DataSourceModule,
],
services: [UserWorkspaceService],
}),
],
exports: [UserWorkspaceService],
providers: [UserWorkspaceService],
})
export class UserWorkspaceModule {}

View File

@ -0,0 +1,65 @@
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm';
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { User } from 'src/core/user/user.entity';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
) {
super(userWorkspaceRepository);
}
async create(userId: string, workspaceId: string): Promise<UserWorkspace> {
const userWorkspace = this.userWorkspaceRepository.create({
userId,
workspaceId,
});
return this.userWorkspaceRepository.save(userWorkspace);
}
async createWorkspaceMember(workspaceId: string, user: User) {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."workspaceMember"
("nameFirstName", "nameLastName", "colorScheme", "userId", "userEmail", "avatarUrl")
VALUES ('${user.firstName}', '${user.lastName}', 'Light', '${
user.id
}', '${user.email}', '${user.defaultAvatarUrl ?? ''}')`,
);
}
async findUserWorkspaces(userId: string): Promise<UserWorkspace[]> {
return this.userWorkspaceRepository.find({
where: {
userId,
},
});
}
async checkUserWorkspaceExists(
userId: string,
workspaceId: string,
): Promise<UserWorkspace | null> {
return this.userWorkspaceRepository.findOneBy({
userId,
workspaceId,
});
}
}

View File

@ -49,7 +49,10 @@ export class UserService extends TypeOrmQueryService<User> {
return;
}
assert(workspaceMembers.length === 1, 'WorkspaceMember not found');
assert(
workspaceMembers.length === 1,
'WorkspaceMember not found or too many found',
);
const userWorkspaceMember = new WorkspaceMember();

View File

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserWorkspaces1707778127558 implements MigrationInterface {
name = 'AddUserWorkspaces1707778127558';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."userWorkspace" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL REFERENCES core.user(id),
"workspaceId" uuid NOT NULL REFERENCES core.workspace(id),
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP
)`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" DROP CONSTRAINT "FK_2ec910029395fa7655621c88908"`,
);
}
public async down(): Promise<void> {}
}