diff --git a/server/src/coreV2/core.module.ts b/server/src/coreV2/core.module.ts index 0c88d53b1..c70030b2b 100644 --- a/server/src/coreV2/core.module.ts +++ b/server/src/coreV2/core.module.ts @@ -8,7 +8,7 @@ import GraphQLJSON from 'graphql-type-json'; // eslint-disable-next-line no-restricted-imports import config from '../../ormconfig'; -import { UserModule } from './userv2/user.module'; +import { UserModule } from './user/user.module'; import { RefreshTokenModule } from './refresh-token/refresh-token.module'; @Module({ diff --git a/server/src/coreV2/refresh-token/refresh-token.entity.ts b/server/src/coreV2/refresh-token/refresh-token.entity.ts index 05da8babc..6a5ff8f04 100644 --- a/server/src/coreV2/refresh-token/refresh-token.entity.ts +++ b/server/src/coreV2/refresh-token/refresh-token.entity.ts @@ -15,7 +15,7 @@ import { IDField, } from '@ptc-org/nestjs-query-graphql'; -import { User } from 'src/coreV2/userv2/user.entity'; +import { User } from 'src/coreV2/user/user.entity'; import { BeforeCreateOneRefreshToken } from './hooks/before-create-one-refresh-token.hook'; diff --git a/server/src/coreV2/user/services/user.service.spec.ts b/server/src/coreV2/user/services/user.service.spec.ts new file mode 100644 index 000000000..519b86792 --- /dev/null +++ b/server/src/coreV2/user/services/user.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { User } from 'src/coreV2/user/user.entity'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], + }).compile(); + + service = module.get(UserService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/coreV2/user/services/user.service.ts b/server/src/coreV2/user/services/user.service.ts new file mode 100644 index 000000000..bf08f3db6 --- /dev/null +++ b/server/src/coreV2/user/services/user.service.ts @@ -0,0 +1,71 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; +import { Repository } from 'typeorm'; + +import { assert } from 'src/utils/assert'; +import { User } from 'src/coreV2/user/user.entity'; + +export class UserService extends TypeOrmQueryService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) { + super(userRepository); + } + + async deleteUser({ + workspaceId: _workspaceId, + userId, + }: { + workspaceId: string; + userId: string; + }) { + // const { workspaceMember, refreshToken } = this.prismaService.client; + + // const queryRunner = + // this.userRepository.manager.connection.createQueryRunner(); + // await queryRunner.connect(); + + const user = await this.userRepository.findBy({ id: userId }); + assert(user, 'User not found'); + + // FIXME: Workspace entity is not defined + // const workspace = await queryRunner.manager.findOneBy(Workspace, { + // id: userId, + // }); + // assert(workspace, 'Workspace not found'); + + // const workSpaceMembers = await queryRunner.manager.findBy(WorkspaceMember, { + // workspaceId, + // }); + + // const isLastMember = + // workSpaceMembers.length === 1 && workSpaceMembers[0].userId === userId; + + // if (isLastMember) { + // // FIXME: workspaceService is not defined + // await this.workspaceService.deleteWorkspace({ + // workspaceId, + // }); + // } else { + // await queryRunner.startTransaction(); + + // // FIXME: these other entities are not defined + // await queryRunner.manager.delete(WorkspaceMember, { + // userId, + // }); + // await queryRunner.manager.delete(RefreshToken, { + // userId, + // }); + // await queryRunner.manager.delete(User, { + // id: userId, + // }); + // await queryRunner.commitTransaction(); + + // await queryRunner.release(); + // } + + return user; + } +} diff --git a/server/src/coreV2/user/user.auto-resolver-opts.ts b/server/src/coreV2/user/user.auto-resolver-opts.ts new file mode 100644 index 000000000..942832586 --- /dev/null +++ b/server/src/coreV2/user/user.auto-resolver-opts.ts @@ -0,0 +1,39 @@ +import { SortDirection } from '@ptc-org/nestjs-query-core'; +import { + AutoResolverOpts, + ReadResolverOpts, + PagingStrategies, +} from '@ptc-org/nestjs-query-graphql'; + +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; + +import { User } from './user.entity'; + +export const userAutoResolverOpts: AutoResolverOpts< + any, + any, + unknown, + unknown, + ReadResolverOpts, + PagingStrategies +>[] = [ + { + EntityClass: User, + DTOClass: User, + enableTotalCount: true, + pagingStrategy: PagingStrategies.CURSOR, + read: { + defaultSort: [{ field: 'id', direction: SortDirection.DESC }], + }, + create: { + many: { disabled: true }, + one: { disabled: true }, + }, + update: { + many: { disabled: true }, + one: { disabled: true }, + }, + delete: { many: { disabled: true }, one: { disabled: true } }, + guards: [JwtAuthGuard], + }, +]; diff --git a/server/src/coreV2/user/user.entity.ts b/server/src/coreV2/user/user.entity.ts new file mode 100644 index 000000000..efb3e6e02 --- /dev/null +++ b/server/src/coreV2/user/user.entity.ts @@ -0,0 +1,93 @@ +import { ID, Field, ObjectType } from '@nestjs/graphql'; + +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { GraphQLJSONObject } from 'graphql-type-json'; + +import { RefreshToken } from 'src/coreV2/refresh-token/refresh-token.entity'; + +@Entity('users') +@ObjectType('user') +// @Authorize({ +// authorize: (context: any) => ({ +// // FIXME: We do not have this relation in the database +// workspaceMember: { +// workspaceId: { eq: context?.req?.user?.workspace?.id }, +// }, +// }), +// }) +export class User { + @IDField(() => ID) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field() + @Column({ nullable: true }) + firstName: string; + + @Field() + @Column({ nullable: true }) + lastName: string; + + @Field() + @Column() + email: string; + + @Field() + @Column({ default: false }) + emailVerified: boolean; + + @Field() + @Column({ nullable: true }) + avatarUrl: string; + + @Field() + @Column() + locale: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + phoneNumber: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + lastSeen: Date; + + @Field({ nullable: true }) + @Column({ default: false }) + disabled: boolean; + + @Field({ nullable: true }) + @Column({ nullable: true }) + passwordHash: string; + + @Field(() => GraphQLJSONObject, { nullable: true }) + @Column({ type: 'json', nullable: true }) + metadata: Record; + + @Field() + @Column({ default: false }) + canImpersonate: boolean; + + @Field() + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date; + + @Field() + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updatedAt: Date; + + @Field({ nullable: true }) + @Column({ nullable: true }) + deletedAt: Date; + + @OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user) + refreshTokens: RefreshToken[]; +} diff --git a/server/src/coreV2/user/user.module.ts b/server/src/coreV2/user/user.module.ts new file mode 100644 index 000000000..4aa07c3fe --- /dev/null +++ b/server/src/coreV2/user/user.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql'; +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { AbilityModule } from 'src/ability/ability.module'; +import { FileModule } from 'src/core/file/file.module'; + +import { User } from './user.entity'; +import { UserResolver } from './user.resolver'; +import { userAutoResolverOpts } from './user.auto-resolver-opts'; + +import { UserService } from './services/user.service'; + +@Module({ + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([User])], + services: [UserService], + resolvers: userAutoResolverOpts, + }), + AbilityModule, + FileModule, + ], + providers: [UserService, UserResolver], +}) +export class UserModule {} diff --git a/server/src/coreV2/user/user.resolver.ts b/server/src/coreV2/user/user.resolver.ts new file mode 100644 index 000000000..606f149ed --- /dev/null +++ b/server/src/coreV2/user/user.resolver.ts @@ -0,0 +1,108 @@ +import { + Resolver, + Query, + Args, + Parent, + ResolveField, + Mutation, +} from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; + +import crypto from 'crypto'; + +import { FileUpload, GraphQLUpload } from 'graphql-upload'; +import { Workspace } from '@prisma/client'; + +import { SupportDriver } from 'src/integrations/environment/interfaces/support.interface'; +import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; + +import { AbilityGuard } from 'src/guards/ability.guard'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { DeleteUserAbilityHandler } from 'src/ability/handlers/user.ability-handler'; +import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; +import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { assert } from 'src/utils/assert'; +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; + +import { User } from './user.entity'; + +import { UserService } from './services/user.service'; + +const getHMACKey = (email?: string, key?: string | null) => { + if (!email || !key) return null; + + const hmac = crypto.createHmac('sha256', key); + return hmac.update(email).digest('hex'); +}; + +@UseGuards(JwtAuthGuard) +@Resolver(() => User) +export class UserResolver { + constructor( + private readonly userService: UserService, + private readonly environmentService: EnvironmentService, + private readonly fileUploadService: FileUploadService, + ) {} + + @Query(() => User) + async currentUser(@AuthUser() { id }: User) { + const user = await this.userService.findById(id); + assert(user, 'User not found'); + return user; + } + + @ResolveField(() => String, { + nullable: false, + }) + displayName(@Parent() parent: User): string { + return `${parent.firstName ?? ''} ${parent.lastName ?? ''}`; + } + + @ResolveField(() => String, { + nullable: true, + }) + supportUserHash(@Parent() parent: User): string | null { + if (this.environmentService.getSupportDriver() !== SupportDriver.Front) { + return null; + } + const key = this.environmentService.getSupportFrontHMACKey(); + return getHMACKey(parent.email, key); + } + + @Mutation(() => String) + async uploadProfilePicture( + @AuthUser() { id }: User, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename, mimetype }: FileUpload, + ): Promise { + const stream = createReadStream(); + const buffer = await streamToBuffer(stream); + const fileFolder = FileFolder.ProfilePicture; + + const { paths } = await this.fileUploadService.uploadImage({ + file: buffer, + filename, + mimeType: mimetype, + fileFolder, + }); + + await this.userService.updateOne(id, { + avatarUrl: paths[0], + }); + + return paths[0]; + } + + @Mutation(() => User) + @UseGuards(AbilityGuard) + @CheckAbilities(DeleteUserAbilityHandler) + async deleteUserAccount( + @AuthUser() { id: userId }: User, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.userService.deleteUser({ userId, workspaceId }); + } +} diff --git a/server/src/coreV2/userv2/user.dto.ts b/server/src/coreV2/userv2/user.dto.ts deleted file mode 100644 index fed9e9632..000000000 --- a/server/src/coreV2/userv2/user.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Field, ID, ObjectType } from '@nestjs/graphql'; - -@ObjectType() -export class TUser { - @Field(() => ID, { nullable: false }) - id: number; - - @Field(() => String, { nullable: true }) - firstName: string | null; - - @Field(() => String, { nullable: true }) - lastName: string | null; - - @Field(() => String, { nullable: false }) - email: string; - - @Field(() => Boolean, { nullable: false, defaultValue: false }) - emailVerified: boolean; -} diff --git a/server/src/coreV2/userv2/user.entity.ts b/server/src/coreV2/userv2/user.entity.ts deleted file mode 100644 index d54f3c81c..000000000 --- a/server/src/coreV2/userv2/user.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; - -import { RefreshToken } from 'src/coreV2/refresh-token/refresh-token.entity'; - -@Entity('users') -export class User { - @PrimaryGeneratedColumn() - id: number; - - @Column() - firstName: string; - - @Column() - lastName: string; - - @Column() - email: string; - - @Column({ default: false }) - emailVerified: boolean; - - @OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user) - refreshTokens: RefreshToken[]; -} diff --git a/server/src/coreV2/userv2/user.module.ts b/server/src/coreV2/userv2/user.module.ts deleted file mode 100644 index 823e38995..000000000 --- a/server/src/coreV2/userv2/user.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { AbilityModule } from 'src/ability/ability.module'; - -import { User } from './user.entity'; -import { UserResolver } from './user.resolver'; -import { UserService } from './user.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([User]), AbilityModule], - providers: [UserService, UserResolver], -}) -export class UserModule {} diff --git a/server/src/coreV2/userv2/user.resolver.ts b/server/src/coreV2/userv2/user.resolver.ts deleted file mode 100644 index c2194891c..000000000 --- a/server/src/coreV2/userv2/user.resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Resolver, Query } from '@nestjs/graphql'; -import { UseFilters, UseGuards } from '@nestjs/common'; - -import { ExceptionFilter } from 'src/filters/exception.filter'; -import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; -import { AbilityGuard } from 'src/guards/ability.guard'; -import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; -import { ReadUserAbilityHandler } from 'src/ability/handlers/user.ability-handler'; - -import { TUser } from './user.dto'; -import { UserService } from './user.service'; -import { User } from './user.entity'; - -@UseGuards(JwtAuthGuard) -@Resolver(() => TUser) -export class UserResolver { - constructor(private readonly userService: UserService) {} - - @UseFilters(ExceptionFilter) - @Query(() => [TUser], { - nullable: false, - }) - @UseGuards(AbilityGuard) - @CheckAbilities(ReadUserAbilityHandler) - async findManyUserV2(): Promise[]> { - return this.userService.findAll(); - } -} diff --git a/server/src/coreV2/userv2/user.service.ts b/server/src/coreV2/userv2/user.service.ts deleted file mode 100644 index 686f27ce0..000000000 --- a/server/src/coreV2/userv2/user.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; - -import { User } from './user.entity'; - -@Injectable() -export class UserService { - constructor( - @InjectRepository(User) - private usersRepository: Repository, - ) {} - - async findAll() { - return this.usersRepository.find(); - } -}