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 <martmull@hotmail.fr>
This commit is contained in:
Aditya Pimpalkar
2024-03-04 15:14:04 +00:00
committed by GitHub
parent 4a0f2e8c24
commit 63d403454c
25 changed files with 363 additions and 22 deletions

View File

@ -4,21 +4,21 @@
"[typescript]": { "[typescript]": {
"editor.formatOnSave": false, "editor.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true, "source.fixAll.eslint": "explicit",
"source.addMissingImports": "always" "source.addMissingImports": "always"
} }
}, },
"[javascript]": { "[javascript]": {
"editor.formatOnSave": false, "editor.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true, "source.fixAll.eslint": "explicit",
"source.addMissingImports": "always" "source.addMissingImports": "always"
} }
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.formatOnSave": false, "editor.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true, "source.fixAll.eslint": "explicit",
"source.addMissingImports": "always" "source.addMissingImports": "always"
} }
}, },

View File

@ -235,6 +235,7 @@ export type Mutation = {
deleteUser: User; deleteUser: User;
emailPasswordResetLink: EmailPasswordResetLink; emailPasswordResetLink: EmailPasswordResetLink;
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateJWT: AuthTokens;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
impersonate: Verify; impersonate: Verify;
renewToken: AuthTokens; renewToken: AuthTokens;
@ -289,6 +290,11 @@ export type MutationGenerateApiKeyTokenArgs = {
}; };
export type MutationGenerateJwtArgs = {
workspaceId: Scalars['String'];
};
export type MutationImpersonateArgs = { export type MutationImpersonateArgs = {
userId: Scalars['String']; userId: Scalars['String'];
}; };
@ -568,6 +574,7 @@ export type User = {
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
defaultAvatarUrl?: Maybe<Scalars['String']>; defaultAvatarUrl?: Maybe<Scalars['String']>;
defaultWorkspace: Workspace; defaultWorkspace: Workspace;
defaultWorkspaceId: Scalars['String'];
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
disabled?: Maybe<Scalars['Boolean']>; disabled?: Maybe<Scalars['Boolean']>;
email: Scalars['String']; email: Scalars['String'];
@ -581,6 +588,7 @@ export type User = {
supportUserHash?: Maybe<Scalars['String']>; supportUserHash?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime']; updatedAt: Scalars['DateTime'];
workspaceMember?: Maybe<WorkspaceMember>; workspaceMember?: Maybe<WorkspaceMember>;
workspaces: Array<UserWorkspace>;
}; };
export type UserEdge = { export type UserEdge = {
@ -596,6 +604,18 @@ export type UserExists = {
exists: Scalars['Boolean']; exists: Scalars['Boolean'];
}; };
export type UserWorkspace = {
__typename?: 'UserWorkspace';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
id: Scalars['ID'];
updatedAt: Scalars['DateTime'];
user: User;
userId: Scalars['String'];
workspace?: Maybe<Workspace>;
workspaceId: Scalars['String'];
};
export type ValidatePasswordResetToken = { export type ValidatePasswordResetToken = {
__typename?: 'ValidatePasswordResetToken'; __typename?: 'ValidatePasswordResetToken';
email: Scalars['String']; email: Scalars['String'];
@ -916,7 +936,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; 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<{ export type ActivateWorkspaceMutationVariables = Exact<{
input: ActivateWorkspaceInput; input: ActivateWorkspaceInput;
@ -1854,6 +1874,14 @@ export const GetCurrentUserDocument = gql`
workspaceId workspaceId
} }
} }
workspaces {
workspace {
id
displayName
logo
domainName
}
}
} }
} }
`; `;

View File

@ -36,6 +36,14 @@ export const GET_CURRENT_USER = gql`
workspaceId workspaceId
} }
} }
workspaces {
workspace {
id
displayName
logo
domainName
}
}
} }
} }
`; `;

View File

@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { UserService } from 'src/core/user/services/user.service'; 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'; import { AuthResolver } from './auth.resolver';
@ -20,6 +22,10 @@ describe('AuthResolver', () => {
provide: getRepositoryToken(Workspace, 'core'), provide: getRepositoryToken(Workspace, 'core'),
useValue: {}, useValue: {},
}, },
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
},
{ {
provide: AuthService, provide: AuthService,
useValue: {}, useValue: {},
@ -32,6 +38,10 @@ describe('AuthResolver', () => {
provide: UserService, provide: UserService,
useValue: {}, useValue: {},
}, },
{
provide: UserWorkspaceService,
useValue: {},
},
], ],
}).compile(); }).compile();

View File

@ -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 { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity';
import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity';
import { EmailPasswordResetLinkInput } from 'src/core/auth/dto/email-password-reset-link.input'; 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 { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
@ -49,6 +51,7 @@ export class AuthResolver {
private authService: AuthService, private authService: AuthService,
private tokenService: TokenService, private tokenService: TokenService,
private userService: UserService, private userService: UserService,
private userWorkspaceService: UserWorkspaceService,
) {} ) {}
@Query(() => UserExists) @Query(() => UserExists)
@ -128,6 +131,20 @@ export class AuthResolver {
return result; return result;
} }
@Mutation(() => AuthTokens)
@UseGuards(JwtAuthGuard)
async generateJWT(
@AuthUser() user: User,
@Args() args: GenerateJwtInput,
): Promise<AuthTokens> {
const token = await this.tokenService.generateSwitchWorkspaceToken(
user,
args.workspaceId,
);
return token;
}
@Mutation(() => AuthTokens) @Mutation(() => AuthTokens)
async renewToken(@Args() args: RefreshTokenInput): Promise<AuthTokens> { async renewToken(@Args() args: RefreshTokenInput): Promise<AuthTokens> {
if (!args.refreshToken) { if (!args.refreshToken) {

View File

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

View File

@ -98,7 +98,7 @@ export class AuthService {
where: { where: {
email, email,
}, },
relations: ['defaultWorkspace'], relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
}); });
assert(user, "This user doesn't exist", NotFoundException); assert(user, "This user doesn't exist", NotFoundException);

View File

@ -7,6 +7,8 @@ import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy'; import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy';
import { EmailService } from 'src/integrations/email/email.service'; 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'; import { TokenService } from './token.service';
@ -33,6 +35,10 @@ describe('TokenService', () => {
provide: EmailService, provide: EmailService,
useValue: {}, useValue: {},
}, },
{
provide: UserWorkspaceService,
useValue: {},
},
{ {
provide: getRepositoryToken(User, 'core'), provide: getRepositoryToken(User, 'core'),
useValue: {}, useValue: {},
@ -41,6 +47,10 @@ describe('TokenService', () => {
provide: getRepositoryToken(RefreshToken, 'core'), provide: getRepositoryToken(RefreshToken, 'core'),
useValue: {}, useValue: {},
}, },
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
},
], ],
}).compile(); }).compile();

View File

@ -29,6 +29,7 @@ import { assert } from 'src/utils/assert';
import { import {
ApiKeyToken, ApiKeyToken,
AuthToken, AuthToken,
AuthTokens,
PasswordResetToken, PasswordResetToken,
} from 'src/core/auth/dto/token.entity'; } from 'src/core/auth/dto/token.entity';
import { EnvironmentService } from 'src/integrations/environment/environment.service'; 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 { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity';
import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity';
import { JwtData } from 'src/core/auth/types/jwt-data.type'; 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() @Injectable()
export class TokenService { export class TokenService {
@ -50,10 +53,16 @@ export class TokenService {
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(RefreshToken, 'core') @InjectRepository(RefreshToken, 'core')
private readonly refreshTokenRepository: Repository<RefreshToken>, private readonly refreshTokenRepository: Repository<RefreshToken>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly emailService: EmailService, private readonly emailService: EmailService,
private readonly userWorkspaceService: UserWorkspaceService,
) {} ) {}
async generateAccessToken(userId: string): Promise<AuthToken> { async generateAccessToken(
userId: string,
workspaceId?: string,
): Promise<AuthToken> {
const expiresIn = this.environmentService.getAccessTokenExpiresIn(); const expiresIn = this.environmentService.getAccessTokenExpiresIn();
assert(expiresIn, '', InternalServerErrorException); assert(expiresIn, '', InternalServerErrorException);
@ -74,7 +83,7 @@ export class TokenService {
const jwtPayload: JwtPayload = { const jwtPayload: JwtPayload = {
sub: user.id, sub: user.id,
workspaceId: user.defaultWorkspace.id, workspaceId: workspaceId ? workspaceId : user.defaultWorkspace.id,
}; };
return { return {
@ -232,6 +241,45 @@ export class TokenService {
}; };
} }
async generateSwitchWorkspaceToken(
user: User,
workspaceId: string,
): Promise<AuthTokens> {
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) { async verifyRefreshToken(refreshToken: string) {
const secret = this.environmentService.getRefreshTokenSecret(); const secret = this.environmentService.getRefreshTokenSecret();
const coolDown = this.environmentService.getRefreshTokenCoolDown(); const coolDown = this.environmentService.getRefreshTokenCoolDown();

View File

@ -6,6 +6,7 @@ import {
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn, JoinColumn,
ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique, Unique,
UpdateDateColumn, UpdateDateColumn,
@ -22,15 +23,25 @@ export class UserWorkspace {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Field(() => User)
@ManyToOne(() => User, (user) => user.workspaces, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: 'userId' })
user: User; user: User;
@Field({ nullable: false })
@Column() @Column()
userId: string; userId: string;
@Field(() => Workspace, { nullable: true })
@ManyToOne(() => Workspace, (workspace) => workspace.workspaceUsers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' }) @JoinColumn({ name: 'workspaceId' })
workspace: Workspace; workspace: Workspace;
@Field({ nullable: false })
@Column() @Column()
workspaceId: string; workspaceId: string;

View File

@ -81,14 +81,6 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
).length; ).length;
} }
async findUserWorkspaces(userId: string): Promise<UserWorkspace[]> {
return this.userWorkspaceRepository.find({
where: {
userId,
},
});
}
async checkUserWorkspaceExists( async checkUserWorkspaceExists(
userId: string, userId: string,
workspaceId: string, workspaceId: string,

View File

@ -83,12 +83,27 @@ export class UserService extends TypeOrmQueryService<User> {
} }
async deleteUser(userId: string): Promise<User> { async deleteUser(userId: string): Promise<User> {
const user = await this.userRepository.findOneBy({ const user = await this.userRepository.findOne({
id: userId, where: {
id: userId,
},
relations: ['defaultWorkspace'],
}); });
assert(user, 'User not found'); 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); await this.userRepository.delete(user.id);
return user; return user;

View File

@ -14,6 +14,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto'; 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' }) @Entity({ name: 'user', schema: 'core' })
@ObjectType('User') @ObjectType('User')
@ -72,6 +73,10 @@ export class User {
}) })
defaultWorkspace: Workspace; defaultWorkspace: Workspace;
@Field()
@Column()
defaultWorkspaceId: string;
@Field({ nullable: true }) @Field({ nullable: true })
@Column({ nullable: true }) @Column({ nullable: true })
passwordResetToken: string; passwordResetToken: string;
@ -87,4 +92,8 @@ export class User {
@Field(() => WorkspaceMember, { nullable: true }) @Field(() => WorkspaceMember, { nullable: true })
workspaceMember: WorkspaceMember; workspaceMember: WorkspaceMember;
@Field(() => [UserWorkspace])
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user)
workspaces: UserWorkspace[];
} }

View File

@ -10,6 +10,8 @@ import { UserResolver } from 'src/core/user/user.resolver';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.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'; import { userAutoResolverOpts } from './user.auto-resolver-opts';
@ -19,13 +21,14 @@ import { UserService } from './services/user.service';
imports: [ imports: [
NestjsQueryGraphQLModule.forFeature({ NestjsQueryGraphQLModule.forFeature({
imports: [ imports: [
NestjsQueryTypeOrmModule.forFeature([User], 'core'), NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'),
TypeORMModule, TypeORMModule,
], ],
resolvers: userAutoResolverOpts, resolvers: userAutoResolverOpts,
}), }),
DataSourceModule, DataSourceModule,
FileModule, FileModule,
UserWorkspaceModule,
], ],
exports: [UserService], exports: [UserService],
providers: [UserService, UserResolver, TypeORMService], providers: [UserService, UserResolver, TypeORMService],

View File

@ -23,8 +23,11 @@ import { assert } from 'src/utils/assert';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto'; 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 { UserService } from './services/user.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
const getHMACKey = (email?: string, key?: string | null) => { const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null; if (!email || !key) return null;
@ -38,15 +41,21 @@ const getHMACKey = (email?: string, key?: string | null) => {
@Resolver(() => User) @Resolver(() => User)
export class UserResolver { export class UserResolver {
constructor( constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userService: UserService, private readonly userService: UserService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly fileUploadService: FileUploadService, private readonly fileUploadService: FileUploadService,
) {} ) {}
@Query(() => User) @Query(() => User)
async currentUser(@AuthUser() { id }: User) { async currentUser(@AuthUser() { id }: User): Promise<User> {
const user = await this.userService.findById(id, { const user = await this.userRepository.findOne({
relations: [{ name: 'defaultWorkspace', query: {} }], where: {
id,
},
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
}); });
assert(user, 'User not found'); assert(user, 'User not found');

View File

@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; 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 { BillingService } from 'src/core/billing/billing.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
@ -19,6 +21,14 @@ describe('WorkspaceService', () => {
provide: getRepositoryToken(Workspace, 'core'), provide: getRepositoryToken(Workspace, 'core'),
useValue: {}, useValue: {},
}, },
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useValue: {},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
},
{ {
provide: WorkspaceManagerService, provide: WorkspaceManagerService,
useValue: {}, useValue: {},

View File

@ -10,6 +10,7 @@ import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspa
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input'; 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 { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { BillingService } from 'src/core/billing/billing.service'; import { BillingService } from 'src/core/billing/billing.service';
@ -17,6 +18,10 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
constructor( constructor(
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly workspaceManagerService: WorkspaceManagerService, private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
@ -49,6 +54,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
assert(workspace, 'Workspace not found'); assert(workspace, 'Workspace not found');
await this.userWorkspaceRepository.delete({ workspaceId: id });
await this.billingService.deleteSubscription(workspace.id); await this.billingService.deleteSubscription(workspace.id);
await this.workspaceManagerService.delete(id); await this.workspaceManagerService.delete(id);

View File

@ -15,6 +15,7 @@ import Stripe from 'stripe';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.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' }) @Entity({ name: 'workspace', schema: 'core' })
@ObjectType('Workspace') @ObjectType('Workspace')
@ -55,6 +56,11 @@ export class Workspace {
@OneToMany(() => User, (user) => user.defaultWorkspace) @OneToMany(() => User, (user) => user.defaultWorkspace)
users: User[]; users: User[];
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.workspace, {
onDelete: 'CASCADE',
})
workspaceUsers: UserWorkspace[];
@Field() @Field()
@Column({ default: true }) @Column({ default: true })
allowImpersonation: boolean; allowImpersonation: boolean;

View File

@ -8,6 +8,8 @@ import { WorkspaceManagerModule } from 'src/workspace/workspace-manager/workspac
import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver'; import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; 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 { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
import { BillingModule } from 'src/core/billing/billing.module'; import { BillingModule } from 'src/core/billing/billing.module';
@ -24,7 +26,7 @@ import { WorkspaceService } from './services/workspace.service';
BillingModule, BillingModule,
FileModule, FileModule,
NestjsQueryTypeOrmModule.forFeature( NestjsQueryTypeOrmModule.forFeature(
[Workspace, FeatureFlagEntity], [User, Workspace, UserWorkspace, FeatureFlagEntity],
'core', 'core',
), ),
UserWorkspaceModule, UserWorkspaceModule,

View File

@ -9,6 +9,10 @@ import {
deleteWorkspaces, deleteWorkspaces,
} from 'src/database/typeorm-seeds/core/demo/workspaces'; } from 'src/database/typeorm-seeds/core/demo/workspaces';
import { deleteFeatureFlags } from 'src/database/typeorm-seeds/core/demo/feature-flags'; 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 ( export const seedCoreSchema = async (
workspaceDataSource: DataSource, workspaceDataSource: DataSource,
@ -18,6 +22,7 @@ export const seedCoreSchema = async (
await seedWorkspaces(workspaceDataSource, schemaName, workspaceId); await seedWorkspaces(workspaceDataSource, schemaName, workspaceId);
await seedUsers(workspaceDataSource, schemaName, workspaceId); await seedUsers(workspaceDataSource, schemaName, workspaceId);
await seedUserWorkspaces(workspaceDataSource, schemaName, workspaceId);
}; };
export const deleteCoreSchema = async ( export const deleteCoreSchema = async (
@ -26,6 +31,7 @@ export const deleteCoreSchema = async (
) => { ) => {
const schemaName = 'core'; const schemaName = 'core';
await deleteUserWorkspaces(workspaceDataSource, schemaName, workspaceId);
await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId); await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId);
await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId); await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId);
// deleteWorkspaces should be last // deleteWorkspaces should be last

View File

@ -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();
};

View File

@ -12,6 +12,10 @@ import {
seedFeatureFlags, seedFeatureFlags,
deleteFeatureFlags, deleteFeatureFlags,
} from 'src/database/typeorm-seeds/core/feature-flags'; } from 'src/database/typeorm-seeds/core/feature-flags';
import {
deleteUserWorkspaces,
seedUserWorkspaces,
} from 'src/database/typeorm-seeds/core/userWorkspaces';
export const seedCoreSchema = async ( export const seedCoreSchema = async (
workspaceDataSource: DataSource, workspaceDataSource: DataSource,
@ -21,6 +25,7 @@ export const seedCoreSchema = async (
await seedWorkspaces(workspaceDataSource, schemaName, workspaceId); await seedWorkspaces(workspaceDataSource, schemaName, workspaceId);
await seedUsers(workspaceDataSource, schemaName, workspaceId); await seedUsers(workspaceDataSource, schemaName, workspaceId);
await seedUserWorkspaces(workspaceDataSource, schemaName, workspaceId);
await seedFeatureFlags(workspaceDataSource, schemaName, workspaceId); await seedFeatureFlags(workspaceDataSource, schemaName, workspaceId);
}; };
@ -30,6 +35,7 @@ export const deleteCoreSchema = async (
) => { ) => {
const schemaName = 'core'; const schemaName = 'core';
await deleteUserWorkspaces(workspaceDataSource, schemaName, workspaceId);
await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId); await deleteUsersByWorkspace(workspaceDataSource, schemaName, workspaceId);
await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId); await deleteFeatureFlags(workspaceDataSource, schemaName, workspaceId);
// deleteWorkspaces should be last // deleteWorkspaces should be last

View File

@ -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();
};

View File

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserWorkspace1709314035408 implements MigrationInterface {
name = 'UpdateUserWorkspace1709314035408';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_cb488f32c6a0827b938edadf221"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_37fdc7357af701e595c5c3a9bd6"`,
);
}
}

View File

@ -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 { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
@Injectable() @Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy { export class TypeORMService implements OnModuleInit, OnModuleDestroy {
@ -26,6 +27,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
entities: [ entities: [
User, User,
Workspace, Workspace,
UserWorkspace,
RefreshToken, RefreshToken,
FeatureFlagEntity, FeatureFlagEntity,
BillingSubscription, BillingSubscription,