From 63d403454ce7f0ca25d376a10f9d841e3652daad Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Mon, 4 Mar 2024 15:14:04 +0000 Subject: [PATCH] feat: multi-workspace followup (#4197) * Seed UserWorkspace for existing demo/dev users * add workspaces field to currentUser * new token generation endpoint for switching workspace * lint fix * include dependency * requested fixes * resolver test pass * changing defaultWorkspace and workspaceMember when switching workspaces * tests fix * requested changes * delete user/workspace edge case handled * after merge * requested changes * :wq! * workspace manytoone relation * lint fix / import fix * gql codegen * Fix migrations and generateJWT * migration fix * relations fix --------- Co-authored-by: martmull --- .vscode/settings.json | 6 +-- .../twenty-front/src/generated/graphql.tsx | 30 ++++++++++- .../users/graphql/queries/getCurrentUser.ts | 8 +++ .../src/core/auth/auth.resolver.spec.ts | 10 ++++ .../src/core/auth/auth.resolver.ts | 17 ++++++ .../src/core/auth/dto/generate-jwt.input.ts | 11 ++++ .../src/core/auth/services/auth.service.ts | 2 +- .../core/auth/services/token.service.spec.ts | 10 ++++ .../src/core/auth/services/token.service.ts | 52 +++++++++++++++++- .../user-workspace/user-workspace.entity.ts | 11 ++++ .../user-workspace/user-workspace.service.ts | 8 --- .../src/core/user/services/user.service.ts | 19 ++++++- .../src/core/user/user.entity.ts | 9 ++++ .../src/core/user/user.module.ts | 5 +- .../src/core/user/user.resolver.ts | 15 ++++-- .../services/workspace.service.spec.ts | 10 ++++ .../workspace/services/workspace.service.ts | 6 +++ .../src/core/workspace/workspace.entity.ts | 6 +++ .../src/core/workspace/workspace.module.ts | 4 +- .../database/typeorm-seeds/core/demo/index.ts | 6 +++ .../typeorm-seeds/core/demo/userWorkspaces.ts | 51 ++++++++++++++++++ .../src/database/typeorm-seeds/core/index.ts | 6 +++ .../typeorm-seeds/core/userWorkspaces.ts | 53 +++++++++++++++++++ .../1709314035408-updateUserWorkspace.ts | 28 ++++++++++ .../src/database/typeorm/typeorm.service.ts | 2 + 25 files changed, 363 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-server/src/core/auth/dto/generate-jwt.input.ts create mode 100644 packages/twenty-server/src/database/typeorm-seeds/core/demo/userWorkspaces.ts create mode 100644 packages/twenty-server/src/database/typeorm-seeds/core/userWorkspaces.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1709314035408-updateUserWorkspace.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 8431fcdba..c57b880bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,21 +4,21 @@ "[typescript]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, "[javascript]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, "[typescriptreact]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2e092eaa2..e698c1189 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -235,6 +235,7 @@ export type Mutation = { deleteUser: User; emailPasswordResetLink: EmailPasswordResetLink; generateApiKeyToken: ApiKeyToken; + generateJWT: AuthTokens; generateTransientToken: TransientToken; impersonate: Verify; renewToken: AuthTokens; @@ -289,6 +290,11 @@ export type MutationGenerateApiKeyTokenArgs = { }; +export type MutationGenerateJwtArgs = { + workspaceId: Scalars['String']; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']; }; @@ -568,6 +574,7 @@ export type User = { createdAt: Scalars['DateTime']; defaultAvatarUrl?: Maybe; defaultWorkspace: Workspace; + defaultWorkspaceId: Scalars['String']; deletedAt?: Maybe; disabled?: Maybe; email: Scalars['String']; @@ -581,6 +588,7 @@ export type User = { supportUserHash?: Maybe; updatedAt: Scalars['DateTime']; workspaceMember?: Maybe; + workspaces: Array; }; export type UserEdge = { @@ -596,6 +604,18 @@ export type UserExists = { exists: Scalars['Boolean']; }; +export type UserWorkspace = { + __typename?: 'UserWorkspace'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + updatedAt: Scalars['DateTime']; + user: User; + userId: Scalars['String']; + workspace?: Maybe; + workspaceId: Scalars['String']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']; @@ -916,7 +936,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; export type ActivateWorkspaceMutationVariables = Exact<{ input: ActivateWorkspaceInput; @@ -1854,6 +1874,14 @@ export const GetCurrentUserDocument = gql` workspaceId } } + workspaces { + workspace { + id + displayName + logo + domainName + } + } } } `; diff --git a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts index b7b10ce78..33dc3ad22 100644 --- a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts @@ -36,6 +36,14 @@ export const GET_CURRENT_USER = gql` workspaceId } } + workspaces { + workspace { + id + displayName + logo + domainName + } + } } } `; diff --git a/packages/twenty-server/src/core/auth/auth.resolver.spec.ts b/packages/twenty-server/src/core/auth/auth.resolver.spec.ts index 6472cd1f0..0eb9ef265 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.spec.ts @@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { UserService } from 'src/core/user/services/user.service'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; +import { User } from 'src/core/user/user.entity'; import { AuthResolver } from './auth.resolver'; @@ -20,6 +22,10 @@ describe('AuthResolver', () => { provide: getRepositoryToken(Workspace, 'core'), useValue: {}, }, + { + provide: getRepositoryToken(User, 'core'), + useValue: {}, + }, { provide: AuthService, useValue: {}, @@ -32,6 +38,10 @@ describe('AuthResolver', () => { provide: UserService, useValue: {}, }, + { + provide: UserWorkspaceService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/core/auth/auth.resolver.ts b/packages/twenty-server/src/core/auth/auth.resolver.ts index 686fcee98..842baa162 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.ts @@ -25,6 +25,8 @@ import { UpdatePasswordViaResetTokenInput } from 'src/core/auth/dto/update-passw import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; import { EmailPasswordResetLinkInput } from 'src/core/auth/dto/email-password-reset-link.input'; +import { GenerateJwtInput } from 'src/core/auth/dto/generate-jwt.input'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -49,6 +51,7 @@ export class AuthResolver { private authService: AuthService, private tokenService: TokenService, private userService: UserService, + private userWorkspaceService: UserWorkspaceService, ) {} @Query(() => UserExists) @@ -128,6 +131,20 @@ export class AuthResolver { return result; } + @Mutation(() => AuthTokens) + @UseGuards(JwtAuthGuard) + async generateJWT( + @AuthUser() user: User, + @Args() args: GenerateJwtInput, + ): Promise { + const token = await this.tokenService.generateSwitchWorkspaceToken( + user, + args.workspaceId, + ); + + return token; + } + @Mutation(() => AuthTokens) async renewToken(@Args() args: RefreshTokenInput): Promise { if (!args.refreshToken) { diff --git a/packages/twenty-server/src/core/auth/dto/generate-jwt.input.ts b/packages/twenty-server/src/core/auth/dto/generate-jwt.input.ts new file mode 100644 index 000000000..cd8b4461e --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/generate-jwt.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class GenerateJwtInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + workspaceId: string; +} diff --git a/packages/twenty-server/src/core/auth/services/auth.service.ts b/packages/twenty-server/src/core/auth/services/auth.service.ts index 18ac914fb..9ebbd063b 100644 --- a/packages/twenty-server/src/core/auth/services/auth.service.ts +++ b/packages/twenty-server/src/core/auth/services/auth.service.ts @@ -98,7 +98,7 @@ export class AuthService { where: { email, }, - relations: ['defaultWorkspace'], + relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], }); assert(user, "This user doesn't exist", NotFoundException); diff --git a/packages/twenty-server/src/core/auth/services/token.service.spec.ts b/packages/twenty-server/src/core/auth/services/token.service.spec.ts index 709799bcf..80401f792 100644 --- a/packages/twenty-server/src/core/auth/services/token.service.spec.ts +++ b/packages/twenty-server/src/core/auth/services/token.service.spec.ts @@ -7,6 +7,8 @@ import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { User } from 'src/core/user/user.entity'; import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy'; import { EmailService } from 'src/integrations/email/email.service'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; +import { Workspace } from 'src/core/workspace/workspace.entity'; import { TokenService } from './token.service'; @@ -33,6 +35,10 @@ describe('TokenService', () => { provide: EmailService, useValue: {}, }, + { + provide: UserWorkspaceService, + useValue: {}, + }, { provide: getRepositoryToken(User, 'core'), useValue: {}, @@ -41,6 +47,10 @@ describe('TokenService', () => { provide: getRepositoryToken(RefreshToken, 'core'), useValue: {}, }, + { + provide: getRepositoryToken(Workspace, 'core'), + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/core/auth/services/token.service.ts b/packages/twenty-server/src/core/auth/services/token.service.ts index 0c27dc043..825caef41 100644 --- a/packages/twenty-server/src/core/auth/services/token.service.ts +++ b/packages/twenty-server/src/core/auth/services/token.service.ts @@ -29,6 +29,7 @@ import { assert } from 'src/utils/assert'; import { ApiKeyToken, AuthToken, + AuthTokens, PasswordResetToken, } from 'src/core/auth/dto/token.entity'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; @@ -39,6 +40,8 @@ import { EmailService } from 'src/integrations/email/email.service'; import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; import { JwtData } from 'src/core/auth/types/jwt-data.type'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; +import { Workspace } from 'src/core/workspace/workspace.entity'; @Injectable() export class TokenService { @@ -50,10 +53,16 @@ export class TokenService { private readonly userRepository: Repository, @InjectRepository(RefreshToken, 'core') private readonly refreshTokenRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, private readonly emailService: EmailService, + private readonly userWorkspaceService: UserWorkspaceService, ) {} - async generateAccessToken(userId: string): Promise { + async generateAccessToken( + userId: string, + workspaceId?: string, + ): Promise { const expiresIn = this.environmentService.getAccessTokenExpiresIn(); assert(expiresIn, '', InternalServerErrorException); @@ -74,7 +83,7 @@ export class TokenService { const jwtPayload: JwtPayload = { sub: user.id, - workspaceId: user.defaultWorkspace.id, + workspaceId: workspaceId ? workspaceId : user.defaultWorkspace.id, }; return { @@ -232,6 +241,45 @@ export class TokenService { }; } + async generateSwitchWorkspaceToken( + user: User, + workspaceId: string, + ): Promise { + const userExists = await this.userRepository.findBy({ id: user.id }); + + assert(userExists, 'User not found', NotFoundException); + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + relations: ['workspaceUsers'], + }); + + assert(workspace, 'workspace doesnt exist', NotFoundException); + + assert( + workspace.workspaceUsers + .map((userWorkspace) => userWorkspace.userId) + .includes(user.id), + 'user does not belong to workspace', + ForbiddenException, + ); + + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); + + const token = await this.generateAccessToken(user.id, workspaceId); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + tokens: { + accessToken: token, + refreshToken, + }, + }; + } + async verifyRefreshToken(refreshToken: string) { const secret = this.environmentService.getRefreshTokenSecret(); const coolDown = this.environmentService.getRefreshTokenCoolDown(); diff --git a/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts b/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts index b6e4bf08b..5029e608f 100644 --- a/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts +++ b/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts @@ -6,6 +6,7 @@ import { CreateDateColumn, Entity, JoinColumn, + ManyToOne, PrimaryGeneratedColumn, Unique, UpdateDateColumn, @@ -22,15 +23,25 @@ export class UserWorkspace { @PrimaryGeneratedColumn('uuid') id: string; + @Field(() => User) + @ManyToOne(() => User, (user) => user.workspaces, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'userId' }) user: User; + @Field({ nullable: false }) @Column() userId: string; + @Field(() => Workspace, { nullable: true }) + @ManyToOne(() => Workspace, (workspace) => workspace.workspaceUsers, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'workspaceId' }) workspace: Workspace; + @Field({ nullable: false }) @Column() workspaceId: string; diff --git a/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts index 5f8716574..62eef8629 100644 --- a/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts @@ -81,14 +81,6 @@ export class UserWorkspaceService extends TypeOrmQueryService { ).length; } - async findUserWorkspaces(userId: string): Promise { - return this.userWorkspaceRepository.find({ - where: { - userId, - }, - }); - } - async checkUserWorkspaceExists( userId: string, workspaceId: string, diff --git a/packages/twenty-server/src/core/user/services/user.service.ts b/packages/twenty-server/src/core/user/services/user.service.ts index 3ffbf816a..c088176a2 100644 --- a/packages/twenty-server/src/core/user/services/user.service.ts +++ b/packages/twenty-server/src/core/user/services/user.service.ts @@ -83,12 +83,27 @@ export class UserService extends TypeOrmQueryService { } async deleteUser(userId: string): Promise { - const user = await this.userRepository.findOneBy({ - id: userId, + const user = await this.userRepository.findOne({ + where: { + id: userId, + }, + relations: ['defaultWorkspace'], }); assert(user, 'User not found'); + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + user.defaultWorkspace.id, + ); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSourceMetadata); + + await workspaceDataSource?.query( + `DELETE FROM ${dataSourceMetadata.schema}."workspaceMember" WHERE "userId" = '${userId}'`, + ); + await this.userRepository.delete(user.id); return user; diff --git a/packages/twenty-server/src/core/user/user.entity.ts b/packages/twenty-server/src/core/user/user.entity.ts index 903938c13..e81c438ed 100644 --- a/packages/twenty-server/src/core/user/user.entity.ts +++ b/packages/twenty-server/src/core/user/user.entity.ts @@ -14,6 +14,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql'; import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; @Entity({ name: 'user', schema: 'core' }) @ObjectType('User') @@ -72,6 +73,10 @@ export class User { }) defaultWorkspace: Workspace; + @Field() + @Column() + defaultWorkspaceId: string; + @Field({ nullable: true }) @Column({ nullable: true }) passwordResetToken: string; @@ -87,4 +92,8 @@ export class User { @Field(() => WorkspaceMember, { nullable: true }) workspaceMember: WorkspaceMember; + + @Field(() => [UserWorkspace]) + @OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user) + workspaces: UserWorkspace[]; } diff --git a/packages/twenty-server/src/core/user/user.module.ts b/packages/twenty-server/src/core/user/user.module.ts index ea98ff03e..75fad2ccc 100644 --- a/packages/twenty-server/src/core/user/user.module.ts +++ b/packages/twenty-server/src/core/user/user.module.ts @@ -10,6 +10,8 @@ import { UserResolver } from 'src/core/user/user.resolver'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -19,13 +21,14 @@ import { UserService } from './services/user.service'; imports: [ NestjsQueryGraphQLModule.forFeature({ imports: [ - NestjsQueryTypeOrmModule.forFeature([User], 'core'), + NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'), TypeORMModule, ], resolvers: userAutoResolverOpts, }), DataSourceModule, FileModule, + UserWorkspaceModule, ], exports: [UserService], providers: [UserService, UserResolver, TypeORMService], diff --git a/packages/twenty-server/src/core/user/user.resolver.ts b/packages/twenty-server/src/core/user/user.resolver.ts index d936d68d6..431a5336a 100644 --- a/packages/twenty-server/src/core/user/user.resolver.ts +++ b/packages/twenty-server/src/core/user/user.resolver.ts @@ -23,8 +23,11 @@ import { assert } from 'src/utils/assert'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { User } from 'src/core/user/user.entity'; import { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; import { UserService } from './services/user.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; const getHMACKey = (email?: string, key?: string | null) => { if (!email || !key) return null; @@ -38,15 +41,21 @@ const getHMACKey = (email?: string, key?: string | null) => { @Resolver(() => User) export class UserResolver { constructor( + @InjectRepository(User, 'core') + private readonly userRepository: Repository, private readonly userService: UserService, + private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, private readonly fileUploadService: FileUploadService, ) {} @Query(() => User) - async currentUser(@AuthUser() { id }: User) { - const user = await this.userService.findById(id, { - relations: [{ name: 'defaultWorkspace', query: {} }], + async currentUser(@AuthUser() { id }: User): Promise { + const user = await this.userRepository.findOne({ + where: { + id, + }, + relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], }); assert(user, 'User not found'); diff --git a/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts index 35894c9fe..2ee3828fb 100644 --- a/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts @@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; +import { User } from 'src/core/user/user.entity'; import { BillingService } from 'src/core/billing/billing.service'; import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; @@ -19,6 +21,14 @@ describe('WorkspaceService', () => { provide: getRepositoryToken(Workspace, 'core'), useValue: {}, }, + { + provide: getRepositoryToken(UserWorkspace, 'core'), + useValue: {}, + }, + { + provide: getRepositoryToken(User, 'core'), + useValue: {}, + }, { provide: WorkspaceManagerService, useValue: {}, diff --git a/packages/twenty-server/src/core/workspace/services/workspace.service.ts b/packages/twenty-server/src/core/workspace/services/workspace.service.ts index ea5bd6163..285cec1b3 100644 --- a/packages/twenty-server/src/core/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/core/workspace/services/workspace.service.ts @@ -10,6 +10,7 @@ import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspa import { Workspace } from 'src/core/workspace/workspace.entity'; import { User } from 'src/core/user/user.entity'; import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; import { BillingService } from 'src/core/billing/billing.service'; @@ -17,6 +18,10 @@ export class WorkspaceService extends TypeOrmQueryService { constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, private readonly workspaceManagerService: WorkspaceManagerService, private readonly userWorkspaceService: UserWorkspaceService, private readonly billingService: BillingService, @@ -49,6 +54,7 @@ export class WorkspaceService extends TypeOrmQueryService { assert(workspace, 'Workspace not found'); + await this.userWorkspaceRepository.delete({ workspaceId: id }); await this.billingService.deleteSubscription(workspace.id); await this.workspaceManagerService.delete(id); diff --git a/packages/twenty-server/src/core/workspace/workspace.entity.ts b/packages/twenty-server/src/core/workspace/workspace.entity.ts index d3efe36d3..ca73019c5 100644 --- a/packages/twenty-server/src/core/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/core/workspace/workspace.entity.ts @@ -15,6 +15,7 @@ import Stripe from 'stripe'; import { User } from 'src/core/user/user.entity'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; @Entity({ name: 'workspace', schema: 'core' }) @ObjectType('Workspace') @@ -55,6 +56,11 @@ export class Workspace { @OneToMany(() => User, (user) => user.defaultWorkspace) users: User[]; + @OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.workspace, { + onDelete: 'CASCADE', + }) + workspaceUsers: UserWorkspace[]; + @Field() @Column({ default: true }) allowImpersonation: boolean; diff --git a/packages/twenty-server/src/core/workspace/workspace.module.ts b/packages/twenty-server/src/core/workspace/workspace.module.ts index 6b2759f35..e38c1fa4e 100644 --- a/packages/twenty-server/src/core/workspace/workspace.module.ts +++ b/packages/twenty-server/src/core/workspace/workspace.module.ts @@ -8,6 +8,8 @@ import { WorkspaceManagerModule } from 'src/workspace/workspace-manager/workspac import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; +import { User } from 'src/core/user/user.entity'; import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module'; import { BillingModule } from 'src/core/billing/billing.module'; @@ -24,7 +26,7 @@ import { WorkspaceService } from './services/workspace.service'; BillingModule, FileModule, NestjsQueryTypeOrmModule.forFeature( - [Workspace, FeatureFlagEntity], + [User, Workspace, UserWorkspace, FeatureFlagEntity], 'core', ), UserWorkspaceModule, diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/demo/index.ts b/packages/twenty-server/src/database/typeorm-seeds/core/demo/index.ts index b99c71bea..3fa7d8b27 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/demo/index.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/demo/index.ts @@ -9,6 +9,10 @@ import { deleteWorkspaces, } from 'src/database/typeorm-seeds/core/demo/workspaces'; import { deleteFeatureFlags } from 'src/database/typeorm-seeds/core/demo/feature-flags'; +import { + deleteUserWorkspaces, + seedUserWorkspaces, +} from 'src/database/typeorm-seeds/core/demo/userWorkspaces'; export const seedCoreSchema = async ( workspaceDataSource: DataSource, @@ -18,6 +22,7 @@ export const seedCoreSchema = async ( await seedWorkspaces(workspaceDataSource, schemaName, workspaceId); await seedUsers(workspaceDataSource, schemaName, workspaceId); + await seedUserWorkspaces(workspaceDataSource, schemaName, workspaceId); }; export const deleteCoreSchema = async ( @@ -26,6 +31,7 @@ export const deleteCoreSchema = async ( ) => { const schemaName = 'core'; + await deleteUserWorkspaces(workspaceDataSource, schemaName, workspaceId); await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId); await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId); // deleteWorkspaces should be last diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/demo/userWorkspaces.ts b/packages/twenty-server/src/database/typeorm-seeds/core/demo/userWorkspaces.ts new file mode 100644 index 000000000..f6e52e175 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm-seeds/core/demo/userWorkspaces.ts @@ -0,0 +1,51 @@ +import { DataSource } from 'typeorm'; + +const tableName = 'userWorkspace'; + +export enum DemoSeedUserIds { + Noah = '20202020-9e3b-46d4-a556-88b9ddc2b035', + Hugo = '20202020-3957-4908-9c36-2929a23f8358', + Julia = '20202020-7169-42cf-bc47-1cfef15264b9', +} + +export const seedUserWorkspaces = async ( + workspaceDataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + await workspaceDataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, ['userId', 'workspaceId']) + .orIgnore() + .values([ + { + userId: DemoSeedUserIds.Noah, + workspaceId: workspaceId, + }, + { + userId: DemoSeedUserIds.Hugo, + workspaceId: workspaceId, + }, + { + userId: DemoSeedUserIds.Julia, + workspaceId: workspaceId, + }, + ]) + .execute(); +}; + +export const deleteUserWorkspaces = async ( + workspaceDataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + await workspaceDataSource + .createQueryBuilder() + .delete() + .from(`${schemaName}.${tableName}`) + .where(`"${tableName}"."workspaceId" = :workspaceId`, { + workspaceId, + }) + .execute(); +}; diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/index.ts b/packages/twenty-server/src/database/typeorm-seeds/core/index.ts index b33dce403..6e5c9921e 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/index.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/index.ts @@ -12,6 +12,10 @@ import { seedFeatureFlags, deleteFeatureFlags, } from 'src/database/typeorm-seeds/core/feature-flags'; +import { + deleteUserWorkspaces, + seedUserWorkspaces, +} from 'src/database/typeorm-seeds/core/userWorkspaces'; export const seedCoreSchema = async ( workspaceDataSource: DataSource, @@ -21,6 +25,7 @@ export const seedCoreSchema = async ( await seedWorkspaces(workspaceDataSource, schemaName, workspaceId); await seedUsers(workspaceDataSource, schemaName, workspaceId); + await seedUserWorkspaces(workspaceDataSource, schemaName, workspaceId); await seedFeatureFlags(workspaceDataSource, schemaName, workspaceId); }; @@ -30,6 +35,7 @@ export const deleteCoreSchema = async ( ) => { const schemaName = 'core'; + await deleteUserWorkspaces(workspaceDataSource, schemaName, workspaceId); await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId); await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId); // deleteWorkspaces should be last diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/userWorkspaces.ts b/packages/twenty-server/src/database/typeorm-seeds/core/userWorkspaces.ts new file mode 100644 index 000000000..39b277182 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm-seeds/core/userWorkspaces.ts @@ -0,0 +1,53 @@ +import { DataSource } from 'typeorm'; + +// import { SeedWorkspaceId } from 'src/database/typeorm-seeds/core/workspaces'; + +const tableName = 'userWorkspace'; + +export enum SeedUserIds { + Tim = '20202020-9e3b-46d4-a556-88b9ddc2b034', + Jony = '20202020-3957-4908-9c36-2929a23f8357', + Phil = '20202020-7169-42cf-bc47-1cfef15264b8', +} + +export const seedUserWorkspaces = async ( + workspaceDataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + await workspaceDataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, ['userId', 'workspaceId']) + .orIgnore() + .values([ + { + userId: SeedUserIds.Tim, + workspaceId, + }, + { + userId: SeedUserIds.Jony, + workspaceId, + }, + { + userId: SeedUserIds.Phil, + workspaceId, + }, + ]) + .execute(); +}; + +export const deleteUserWorkspaces = async ( + workspaceDataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + await workspaceDataSource + .createQueryBuilder() + .delete() + .from(`${schemaName}.${tableName}`) + .where(`"${tableName}"."workspaceId" = :workspaceId`, { + workspaceId, + }) + .execute(); +}; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1709314035408-updateUserWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1709314035408-updateUserWorkspace.ts new file mode 100644 index 000000000..b3bd23110 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1709314035408-updateUserWorkspace.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateUserWorkspace1709314035408 implements MigrationInterface { + name = 'UpdateUserWorkspace1709314035408'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "core"."userWorkspace" + ADD CONSTRAINT "FK_37fdc7357af701e595c5c3a9bd6" + FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") + ON DELETE CASCADE ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "core"."userWorkspace" + ADD CONSTRAINT "FK_cb488f32c6a0827b938edadf221" + FOREIGN KEY ("userId") REFERENCES "core"."user"("id") + ON DELETE CASCADE ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_cb488f32c6a0827b938edadf221"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_37fdc7357af701e595c5c3a9bd6"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 837bacef6..1aa3f260f 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -10,6 +10,7 @@ import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; +import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { @@ -26,6 +27,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { entities: [ User, Workspace, + UserWorkspace, RefreshToken, FeatureFlagEntity, BillingSubscription,