diff --git a/nest-cli.json b/nest-cli.json index f9aa683..e8552c2 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "plugins": ["@nestjs/swagger"] } } diff --git a/package.json b/package.json index 5fa96e8..4da8edc 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.4.0", "@prisma/client": "^7.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bef414..858fcaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/swagger': + specifier: ^11.2.6 + version: 11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2) @@ -985,6 +988,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@mrleebo/prisma-ast@0.13.1': resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} engines: {node: '>=16'} @@ -1080,6 +1086,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.2.6': + resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.13': resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==} peerDependencies: @@ -1185,6 +1208,9 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} @@ -3715,6 +3741,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.31.0: + resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -4986,6 +5015,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@mrleebo/prisma-ast@0.13.1': dependencies: chevrotain: 10.5.0 @@ -5107,6 +5138,21 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.17.23 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.31.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.3 + '@nestjs/testing@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-express@11.1.13)': dependencies: '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5227,6 +5273,8 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@scarf/scarf@1.4.0': {} + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': @@ -7977,6 +8025,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.31.0: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} synckit@0.11.12: diff --git a/src/app.controller.ts b/src/app.controller.ts index 7574c33..00a2915 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get } from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' import { AppService } from './app.service' import { Public } from './auth/decorator' +@ApiTags('App') @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index f50483a..c331c98 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { Post, UseGuards, } from '@nestjs/common' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' import { OtpService } from 'src/otp/otp.service' import { TokenService } from 'src/token/token.service' import { CreateUserDto } from 'src/user/dto' @@ -22,6 +23,7 @@ import { } from './dto' import { RefreshTokenGuard } from './guard' +@ApiTags('Auth') @Controller('auth') export class AuthController { constructor( @@ -43,6 +45,7 @@ export class AuthController { return this.authService.login(loginDto) } + @ApiBearerAuth() @HttpCode(HttpStatus.OK) @Post('logout-all') logoutAll(@User('sub') userId: number) { @@ -50,6 +53,7 @@ export class AuthController { } @Public() + @ApiBearerAuth() @UseGuards(RefreshTokenGuard) @HttpCode(HttpStatus.OK) @Post('refresh-token') @@ -61,6 +65,7 @@ export class AuthController { } @Public() + @ApiBearerAuth() @UseGuards(RefreshTokenGuard) @HttpCode(HttpStatus.OK) @Post('revoke-refresh-token') @@ -68,6 +73,7 @@ export class AuthController { return this.tokenService.revokeRefreshToken(refreshTokenId) } + @ApiBearerAuth() @Patch('change-email') changeEmail( @User('email') oldEmail: string, @@ -76,6 +82,7 @@ export class AuthController { return this.authService.changeEmail(oldEmail, changeEmailDto) } + @ApiBearerAuth() @Patch('change-password') changePassword( @User('email') email: string, @@ -97,6 +104,7 @@ export class AuthController { return this.otpService.emailOtp(emailOtpDto.email) } + @ApiBearerAuth() @HttpCode(HttpStatus.OK) @Post('guarded-email-otp') guardedEmailOtp(@User('email') email: string) { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index f7cce31..e4d8a26 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common' import * as argon from 'argon2' +import { UserAlreadyExistsException } from 'src/custom-exceptions' import { PrismaService } from 'src/infra/prisma/prisma.service' import { OtpService } from 'src/otp/otp.service' import { TokenService } from 'src/token/token.service' @@ -27,6 +28,13 @@ export class AuthService { ) {} public async signup(createUserDto: CreateUserDto) { + // validate if a user exists with the same email + const existingUser = await this.prisma.user.findUnique({ + where: { email: createUserDto.email }, + select: { email: true }, + }) + if (existingUser) + throw new UserAlreadyExistsException('email', existingUser.email) const { emailVerifiedCode, ...dto } = createUserDto await this.otpService.verifyCode(dto.email, emailVerifiedCode) const user = await this.usersService.createUser(dto) @@ -44,7 +52,7 @@ export class AuthService { // if user does not exist, throw exception if (!user) throw new NotFoundException('User not found') // check user is not active (deleted) - if (user.deleted) throw new ForbiddenException('User inactive') + if (user.deleted) throw new ForbiddenException('User account inactive') // compare password const pwMatches = await argon.verify(user.password, loginDto.password) // if the password incorrect, throw exception diff --git a/src/auth/dto/change-email.dto.ts b/src/auth/dto/change-email.dto.ts index c8c7a44..7aae75d 100644 --- a/src/auth/dto/change-email.dto.ts +++ b/src/auth/dto/change-email.dto.ts @@ -1,14 +1,19 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsEmail, IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'ChangeEmailRequest' }) export class ChangeEmailDto { + @ApiProperty({ example: 'new_email@example.com' }) @IsEmail() @IsNotEmpty() newEmail: string + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() oldEmailVerifiedCode: string + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() newEmailVerifiedCode: string diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts index 65bea5b..e8aa2ce 100644 --- a/src/auth/dto/change-password.dto.ts +++ b/src/auth/dto/change-password.dto.ts @@ -1,14 +1,19 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'ChangePasswordRequest' }) export class ChangePasswordDto { + @ApiProperty({ example: 'oldPassword123' }) @IsString() @IsNotEmpty() oldPassword: string + @ApiProperty({ example: 'newPassword123' }) @IsString() @IsNotEmpty() newPassword: string + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() emailVerifiedCode: string diff --git a/src/auth/dto/forgot-password.dto.ts b/src/auth/dto/forgot-password.dto.ts index fe57fff..b9c709f 100644 --- a/src/auth/dto/forgot-password.dto.ts +++ b/src/auth/dto/forgot-password.dto.ts @@ -1,14 +1,19 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'ForgotPasswordRequest' }) export class ForgotPasswordDto { + @ApiProperty({ example: 'user@example.com' }) @IsString() @IsNotEmpty() email: string + @ApiProperty({ example: 'newPassword123' }) @IsString() @IsNotEmpty() newPassword: string + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() emailVerifiedCode: string diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index 092f2d8..1cb8fca 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,10 +1,20 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsEmail, IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'LoginRequest' }) export class LoginDto { + @ApiProperty({ + example: 'user@example.com', + description: 'The email address of the user', + }) @IsEmail() @IsNotEmpty() email: string + @ApiProperty({ + example: 'password123', + description: 'The password of the user', + }) @IsString() @IsNotEmpty() password: string diff --git a/src/auth/dto/otp.dto.ts b/src/auth/dto/otp.dto.ts index 3c91b59..f648b23 100644 --- a/src/auth/dto/otp.dto.ts +++ b/src/auth/dto/otp.dto.ts @@ -1,16 +1,22 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsEmail, IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'EmailOtpRequest' }) export class EmailOtpDto { + @ApiProperty({ example: 'user@example.com' }) @IsEmail() @IsNotEmpty() email: string } +@ApiSchema({ name: 'VerifyOtpRequest' }) export class VerifyOtpDto { + @ApiProperty({ example: 'user@example.com' }) @IsEmail() @IsNotEmpty() email: string + @ApiProperty({ example: '123456' }) @IsString() @IsNotEmpty() otp: string diff --git a/src/auth/guard/auth.guard.spec.ts b/src/auth/guard/auth.guard.spec.ts index d7dc592..f51b914 100644 --- a/src/auth/guard/auth.guard.spec.ts +++ b/src/auth/guard/auth.guard.spec.ts @@ -104,7 +104,7 @@ describe('AuthGuard', () => { mockPrisma.user.findUnique.mockResolvedValue(null) await expect(guard.canActivate(context)).rejects.toThrow( - new NotFoundException('User not exists'), + new NotFoundException('Your account not exists'), ) }) @@ -115,7 +115,7 @@ describe('AuthGuard', () => { mockPrisma.user.findUnique.mockResolvedValue({ deleted: true }) await expect(guard.canActivate(context)).rejects.toThrow( - new ForbiddenException('User inactive'), + new ForbiddenException('Your account is inactive'), ) }) @@ -129,7 +129,7 @@ describe('AuthGuard', () => { }) // user version 2 await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('Token revoked'), + new UnauthorizedException('Access token revoked'), ) }) diff --git a/src/auth/guard/auth.guard.ts b/src/auth/guard/auth.guard.ts index 68253a2..1166102 100644 --- a/src/auth/guard/auth.guard.ts +++ b/src/auth/guard/auth.guard.ts @@ -51,12 +51,12 @@ export class AuthGuard implements CanActivate { where: { id: payload.sub }, select: { email: true, role: true, tokenVersion: true, deleted: true }, }) - if (!user) throw new NotFoundException('User not exists') + if (!user) throw new NotFoundException('Your account not exists') // check user is not active (deleted) - if (user.deleted) throw new ForbiddenException('User inactive') + if (user.deleted) throw new ForbiddenException('Your account is inactive') if (user.tokenVersion !== payload.version) { - throw new UnauthorizedException('Token revoked') + throw new UnauthorizedException('Access token revoked') } request[REQUEST_USER_KEY] = { diff --git a/src/custom-exceptions/index.ts b/src/custom-exceptions/index.ts new file mode 100644 index 0000000..bb5662c --- /dev/null +++ b/src/custom-exceptions/index.ts @@ -0,0 +1 @@ +export * from './user-already-exists.exception' diff --git a/src/custom-exceptions/user-already-exists.exception.ts b/src/custom-exceptions/user-already-exists.exception.ts new file mode 100644 index 0000000..29b73fd --- /dev/null +++ b/src/custom-exceptions/user-already-exists.exception.ts @@ -0,0 +1,10 @@ +import { HttpException, HttpStatus } from '@nestjs/common' + +export class UserAlreadyExistsException extends HttpException { + constructor(fieldName: string, fieldValue: string) { + super( + `User with ${fieldName} '${fieldValue}' already exists`, + HttpStatus.CONFLICT, + ) + } +} diff --git a/src/main.ts b/src/main.ts index ad0ad46..88b1798 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,30 @@ import { ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import { AppModule } from './app.module' async function bootstrap() { const app = await NestFactory.create(AppModule) + + app.setGlobalPrefix('api') + app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, }), ) + + const SWAGGER_DOC_URL = 'api/swagger-doc' + const config = new DocumentBuilder() + .setTitle('Auth Platform') + .setDescription('The auth-platform API description') + .setVersion('1.0') + .addBearerAuth() + .build() + const documentFactory = () => SwaggerModule.createDocument(app, config) + SwaggerModule.setup(SWAGGER_DOC_URL, app, documentFactory) + await app.listen(process.env.PORT ?? 3000) } void bootstrap() diff --git a/src/token/token.service.ts b/src/token/token.service.ts index 404f08f..d0a0650 100644 --- a/src/token/token.service.ts +++ b/src/token/token.service.ts @@ -79,7 +79,7 @@ export class TokenService { }) if (!user) throw new NotFoundException('User not exists') // check user is not active (deleted) - if (user.deleted) throw new ForbiddenException('User inactive') + if (user.deleted) throw new ForbiddenException('User account is inactive') // delete used refresh token from db await this.prisma.refreshToken.delete({ where: { id: tokenId } }) // generate access token and refresh token diff --git a/src/user/dto/change-user-role.dto.ts b/src/user/dto/change-user-role.dto.ts index 9a09f9e..6772002 100644 --- a/src/user/dto/change-user-role.dto.ts +++ b/src/user/dto/change-user-role.dto.ts @@ -1,7 +1,10 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsEnum } from 'class-validator' import { Role } from 'generated/prisma/enums' +@ApiSchema({ name: 'ChangeUserRoleRequest' }) export class ChangeUserRoleDto { + @ApiProperty({ enum: Role, enumName: 'Role', example: Role.USER }) @IsEnum(Role) role: Role } diff --git a/src/user/dto/create-user.dto.ts b/src/user/dto/create-user.dto.ts index 2e06d58..47260d4 100644 --- a/src/user/dto/create-user.dto.ts +++ b/src/user/dto/create-user.dto.ts @@ -1,22 +1,29 @@ +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator' +@ApiSchema({ name: 'CreateUserRequest' }) export class CreateUserDto { + @ApiProperty({ example: 'user@example.com' }) @IsEmail() @IsNotEmpty() email: string + @ApiProperty({ example: 'password123' }) @IsString() @IsNotEmpty() password: string + @ApiPropertyOptional({ example: 'John' }) @IsString() @IsOptional() firstName?: string + @ApiPropertyOptional({ example: 'Doe' }) @IsString() @IsOptional() lastName?: string + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() emailVerifiedCode: string diff --git a/src/user/dto/delete-user.dto.ts b/src/user/dto/delete-user.dto.ts index 7b04606..f1808ff 100644 --- a/src/user/dto/delete-user.dto.ts +++ b/src/user/dto/delete-user.dto.ts @@ -1,6 +1,9 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' import { IsNotEmpty, IsString } from 'class-validator' +@ApiSchema({ name: 'DeleteUserRequest' }) export class DeleteUserDto { + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) @IsString() @IsNotEmpty() emailVerifiedCode: string diff --git a/src/user/dto/update-user.dto.ts b/src/user/dto/update-user.dto.ts index 83751d3..5bfe92b 100644 --- a/src/user/dto/update-user.dto.ts +++ b/src/user/dto/update-user.dto.ts @@ -1,10 +1,14 @@ +import { ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' import { IsOptional, IsString } from 'class-validator' +@ApiSchema({ name: 'UpdateUserRequest' }) export class UpdateUserDto { + @ApiPropertyOptional({ example: 'John' }) @IsString() @IsOptional() firstName?: string + @ApiPropertyOptional({ example: 'Doe' }) @IsString() @IsOptional() lastName?: string diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index f9ba1ec..dc35865 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -8,10 +8,13 @@ import { Patch, } from '@nestjs/common' import { Role } from 'generated/prisma/enums' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' import { Roles, User } from 'src/auth/decorator' import { ChangeUserRoleDto, DeleteUserDto, UpdateUserDto } from './dto' import { UserService } from './user.service' +@ApiTags('Users') +@ApiBearerAuth() @Controller('users') export class UserController { constructor(private readonly userService: UserService) {}