From 31b3f6cb2fc662623df92cdbaf803f1b98a696ae Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 5 Dec 2022 10:02:19 +0100 Subject: [PATCH] feat: add user operations to backend --- backend/package-lock.json | 67 +++++------------------ backend/package.json | 2 +- backend/src/auth/dto/authRegister.dto.ts | 17 +----- backend/src/auth/dto/authSignIn.dto.ts | 17 ++++-- backend/src/user/dto/publicUser.dto.ts | 2 +- backend/src/user/dto/updateOwnUser.dto.ts | 6 ++ backend/src/user/dto/updateUser.dto.ts | 4 ++ backend/src/user/dto/user.dto.ts | 22 ++++++-- backend/src/user/user.controller.ts | 54 +++++++++++++++++- backend/src/user/user.module.ts | 2 + backend/src/user/user.service.ts | 64 ++++++++++++++++++++++ 11 files changed, 176 insertions(+), 81 deletions(-) create mode 100644 backend/src/user/dto/updateOwnUser.dto.ts create mode 100644 backend/src/user/dto/updateUser.dto.ts create mode 100644 backend/src/user/user.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index cdb1b31..81134f0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,10 +12,10 @@ "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.1.2", "@nestjs/jwt": "^9.0.0", + "@nestjs/mapped-types": "^1.2.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.1.2", "@nestjs/schedule": "^2.1.0", - "@nestjs/swagger": "^6.1.2", "@nestjs/throttler": "^3.1.0", "archiver": "^5.3.1", "argon2": "^0.29.1", @@ -656,9 +656,9 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.1.0.tgz", - "integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", + "integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==", "peerDependencies": { "@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0", "class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", @@ -838,29 +838,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, - "node_modules/@nestjs/swagger": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.2.tgz", - "integrity": "sha512-RU1DeTDyuN/lRXKFWaf7I9LYF34/ale3IIGeY3romAcXL/N9W0+50Ek3ou+Ajd5FqpLqzt7saYhnaQegVuU4UQ==", - "dependencies": { - "@nestjs/mapped-types": "1.1.0", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.2.0", - "swagger-ui-dist": "4.14.0" - }, - "peerDependencies": { - "@fastify/static": "^6.0.0", - "@nestjs/common": "^9.0.0", - "@nestjs/core": "^9.0.0", - "reflect-metadata": "^0.1.12" - }, - "peerDependenciesMeta": { - "@fastify/static": { - "optional": true - } - } - }, "node_modules/@nestjs/testing": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.1.2.tgz", @@ -1923,7 +1900,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-flatten": { "version": "1.1.1", @@ -4276,6 +4254,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6510,11 +6489,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swagger-ui-dist": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz", - "integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw==" - }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -7872,9 +7846,9 @@ } }, "@nestjs/mapped-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.1.0.tgz", - "integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", + "integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==", "requires": {} }, "@nestjs/passport": { @@ -8005,18 +7979,6 @@ } } }, - "@nestjs/swagger": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.2.tgz", - "integrity": "sha512-RU1DeTDyuN/lRXKFWaf7I9LYF34/ale3IIGeY3romAcXL/N9W0+50Ek3ou+Ajd5FqpLqzt7saYhnaQegVuU4UQ==", - "requires": { - "@nestjs/mapped-types": "1.1.0", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.2.0", - "swagger-ui-dist": "4.14.0" - } - }, "@nestjs/testing": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.1.2.tgz", @@ -8854,7 +8816,8 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "array-flatten": { "version": "1.1.1", @@ -10632,6 +10595,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "requires": { "argparse": "^2.0.1" } @@ -12320,11 +12284,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "swagger-ui-dist": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz", - "integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw==" - }, "symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index a58b3a3..9212ea7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,10 +17,10 @@ "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.1.2", "@nestjs/jwt": "^9.0.0", + "@nestjs/mapped-types": "^1.2.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.1.2", "@nestjs/schedule": "^2.1.0", - "@nestjs/swagger": "^6.1.2", "@nestjs/throttler": "^3.1.0", "archiver": "^5.3.1", "argon2": "^0.29.1", diff --git a/backend/src/auth/dto/authRegister.dto.ts b/backend/src/auth/dto/authRegister.dto.ts index 14a80e7..1860cb4 100644 --- a/backend/src/auth/dto/authRegister.dto.ts +++ b/backend/src/auth/dto/authRegister.dto.ts @@ -1,17 +1,6 @@ -import { PickType } from "@nestjs/swagger"; -import { Expose } from "class-transformer"; -import { IsEmail, Length, Matches } from "class-validator"; + +import { PickType } from "@nestjs/mapped-types"; import { UserDTO } from "src/user/dto/user.dto"; -export class AuthRegisterDTO extends PickType(UserDTO, ["password"] as const) { - @Expose() - @Matches("^[a-zA-Z0-9_.]*$", undefined, { - message: "Username can only contain letters, numbers, dots and underscores", - }) - @Length(3, 32) - username: string; - - @Expose() - @IsEmail() - email: string; +export class AuthRegisterDTO extends PickType(UserDTO, ["email", "username", "password"] as const) { } diff --git a/backend/src/auth/dto/authSignIn.dto.ts b/backend/src/auth/dto/authSignIn.dto.ts index a6cf7cf..0dfa2e2 100644 --- a/backend/src/auth/dto/authSignIn.dto.ts +++ b/backend/src/auth/dto/authSignIn.dto.ts @@ -1,8 +1,13 @@ -import { PickType } from "@nestjs/swagger"; +import { PickType } from "@nestjs/mapped-types"; +import { IsEmail, IsOptional, IsString } from "class-validator"; import { UserDTO } from "src/user/dto/user.dto"; -export class AuthSignInDTO extends PickType(UserDTO, [ - "username", - "email", - "password", -] as const) {} +export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) { + @IsEmail() + @IsOptional() + email: string; + + @IsString() + @IsOptional() + username: string; +} diff --git a/backend/src/user/dto/publicUser.dto.ts b/backend/src/user/dto/publicUser.dto.ts index 2ae7eb4..e14269f 100644 --- a/backend/src/user/dto/publicUser.dto.ts +++ b/backend/src/user/dto/publicUser.dto.ts @@ -1,4 +1,4 @@ -import { PickType } from "@nestjs/swagger"; +import { PickType } from "@nestjs/mapped-types"; import { UserDTO } from "./user.dto"; export class PublicUserDTO extends PickType(UserDTO, ["email"] as const) {} diff --git a/backend/src/user/dto/updateOwnUser.dto.ts b/backend/src/user/dto/updateOwnUser.dto.ts new file mode 100644 index 0000000..b3cfddb --- /dev/null +++ b/backend/src/user/dto/updateOwnUser.dto.ts @@ -0,0 +1,6 @@ +import { OmitType, PartialType } from "@nestjs/mapped-types"; +import { UserDTO } from "./user.dto"; + +export class UpdateOwnUserDTO extends PartialType( + OmitType(UserDTO, ["isAdmin"] as const) +) {} diff --git a/backend/src/user/dto/updateUser.dto.ts b/backend/src/user/dto/updateUser.dto.ts new file mode 100644 index 0000000..e38359d --- /dev/null +++ b/backend/src/user/dto/updateUser.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { UserDTO } from "./user.dto"; + +export class UpdateUserDto extends PartialType(UserDTO) {} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 479c99d..512d174 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -1,17 +1,25 @@ import { Expose, plainToClass } from "class-transformer"; -import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { + IsEmail, + IsNotEmpty, + IsString, + Length, + Matches, +} from "class-validator"; export class UserDTO { @Expose() id: string; @Expose() - @IsOptional() - @IsString() + @Expose() + @Matches("^[a-zA-Z0-9_.]*$", undefined, { + message: "Username can only contain letters, numbers, dots and underscores", + }) + @Length(3, 32) username: string; @Expose() - @IsOptional() @IsEmail() email: string; @@ -25,4 +33,10 @@ export class UserDTO { from(partial: Partial) { return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); } + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(UserDTO, part, { excludeExtraneousValues: true }) + ); + } } diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index a0292d4..d517f8b 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,14 +1,66 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; import { User } from "@prisma/client"; import { GetUser } from "src/auth/decorator/getUser.decorator"; +import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { UpdateUserDto } from "./dto/updateUser.dto"; import { UserDTO } from "./dto/user.dto"; +import { UserSevice } from "./user.service"; @Controller("users") export class UserController { + constructor(private userService: UserSevice) {} + + // Own user operations @Get("me") @UseGuards(JwtGuard) async getCurrentUser(@GetUser() user: User) { return new UserDTO().from(user); } + + @Patch("me") + @UseGuards(JwtGuard) + async updateCurrentUser(@GetUser() user: User, @Body() data: UpdateUserDto) { + return new UserDTO().from(await this.userService.update(user.id, data)); + } + + @Delete("me") + @UseGuards(JwtGuard) + async deleteCurrentUser(@GetUser() user: User) { + return new UserDTO().from(await this.userService.delete(user.id)); + } + + // Global user operations + @Get() + @UseGuards(JwtGuard, AdministratorGuard) + async list() { + return new UserDTO().fromList(await this.userService.list()); + } + + @Post() + @UseGuards(JwtGuard, AdministratorGuard) + async create(@Body() user: UserDTO) { + return new UserDTO().from(await this.userService.create(user)); + } + + @Patch(":id") + @UseGuards(JwtGuard, AdministratorGuard) + async update(@Param("id") id: string, @Body() user: UpdateUserDto) { + return new UserDTO().from(await this.userService.update(id, user)); + } + + @Delete(":id") + @UseGuards(JwtGuard, AdministratorGuard) + async delete(@Param() id: string) { + return new UserDTO().from(await this.userService.delete(id)); + } } diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 9d45d27..97150ce 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,7 +1,9 @@ import { Module } from "@nestjs/common"; import { UserController } from "./user.controller"; +import { UserSevice } from "./user.service"; @Module({ + providers: [UserSevice], controllers: [UserController], }) export class UserModule {} diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts new file mode 100644 index 0000000..fdb7ce5 --- /dev/null +++ b/backend/src/user/user.service.ts @@ -0,0 +1,64 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import * as argon from "argon2"; +import { PrismaService } from "src/prisma/prisma.service"; +import { UpdateUserDto } from "./dto/updateUser.dto"; +import { UserDTO } from "./dto/user.dto"; + +@Injectable() +export class UserSevice { + constructor(private prisma: PrismaService) {} + + async list() { + return await this.prisma.user.findMany(); + } + + async get(id: string) { + return await this.prisma.user.findUnique({ where: { id } }); + } + + async create(dto: UserDTO) { + const hash = await argon.hash(dto.password); + try { + return await this.prisma.user.create({ + data: { + ...dto, + password: hash, + }, + }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + if (e.code == "P2002") { + const duplicatedField: string = e.meta.target[0]; + throw new BadRequestException( + `A user with this ${duplicatedField} already exists` + ); + } + } + } + } + + async update(id: string, user: UpdateUserDto) { + try { + const hash = user.password && (await argon.hash(user.password)); + + return await this.prisma.user.update({ + where: { id }, + data: { ...user, password: hash }, + }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + if (e.code == "P2002") { + const duplicatedField: string = e.meta.target[0]; + throw new BadRequestException( + `A user with this ${duplicatedField} already exists` + ); + } + } + } + } + + async delete(id: string) { + return await this.prisma.user.delete({ where: { id } }); + } +}