From b6ef6db4f3b9573f7d6b8ee5807e6e36368f6194 Mon Sep 17 00:00:00 2001 From: Nikita Mashchenko <52038455+nmashchenko@users.noreply.github.com> Date: Sun, 3 Dec 2023 05:55:06 -0600 Subject: [PATCH] fix: server cleanup (#136) * feat: new filters approach * fix: old issues * feat: new jwt approach to hash * fix: small issues on frontend * fix: lint errors --- client/src/app/(auth)/signup/page.tsx | 6 +- .../entities/session/api/useConfirmEmail.tsx | 13 +- client/src/shared/constant/client-routes.ts | 2 + server/.nvmrc | 1 + server/docs/database.md | 99 ++++++++++++ server/package.json | 3 +- server/src/app.module.ts | 2 - server/src/config/app.config.ts | 4 +- server/src/config/auth.config.ts | 16 ++ server/src/config/config.type.ts | 4 + ...ateUser.ts => 1701598001777-CreateUser.ts} | 22 +-- .../auth-github/auth-github.controller.ts | 5 +- .../auth-google/auth-google.controller.ts | 5 +- .../src/modules/auth/base/auth.controller.ts | 4 +- server/src/modules/auth/base/auth.module.ts | 10 +- server/src/modules/auth/base/auth.service.ts | 152 +++++++++++------- .../base/strategies/jwt-refresh.strategy.ts | 2 +- .../auth/base/strategies/jwt.strategy.ts | 2 +- .../modules/forgot/entities/forgot.entity.ts | 35 ---- server/src/modules/forgot/forgot.module.ts | 11 -- server/src/modules/forgot/forgot.service.ts | 34 ---- server/src/modules/mail/mail.service.ts | 30 ++-- server/src/modules/users/dto/find-user.dto.ts | 71 -------- .../src/modules/users/dto/query-user.dto.ts | 127 +++++++++++++++ .../src/modules/users/entities/user.entity.ts | 5 - server/src/modules/users/users.controller.ts | 23 ++- server/src/modules/users/users.service.ts | 68 ++++---- server/src/utils/infinity-pagination.ts | 2 +- server/src/utils/types/pagination-options.ts | 3 +- server/src/utils/validation-options.ts | 24 ++- server/test/user/auth.e2e-spec.ts | 12 +- server/test/user/users.e2e-spec.ts | 39 ++--- server/yarn.lock | 13 +- 33 files changed, 499 insertions(+), 350 deletions(-) create mode 100644 server/.nvmrc rename server/src/libs/database/migrations/{1701284493876-CreateUser.ts => 1701598001777-CreateUser.ts} (78%) delete mode 100644 server/src/modules/forgot/entities/forgot.entity.ts delete mode 100644 server/src/modules/forgot/forgot.module.ts delete mode 100644 server/src/modules/forgot/forgot.service.ts delete mode 100644 server/src/modules/users/dto/find-user.dto.ts create mode 100644 server/src/modules/users/dto/query-user.dto.ts diff --git a/client/src/app/(auth)/signup/page.tsx b/client/src/app/(auth)/signup/page.tsx index 0d113c4ac..263fc3e24 100644 --- a/client/src/app/(auth)/signup/page.tsx +++ b/client/src/app/(auth)/signup/page.tsx @@ -20,7 +20,7 @@ export default function SignupPage() { const [password, setPassword] = useState(''); const [repeatPassword, setRepeatPassword] = useState(''); const router = useRouter(); - const { mutate: registerUser } = useRegister(); + const { mutate: registerUser, isPending } = useRegister(); const login = useGoogleLogin({ onSuccess: codeResponse => router.push(`/proxy/google?code=${codeResponse.code}`), @@ -76,7 +76,9 @@ export default function SignupPage() { - +
diff --git a/client/src/entities/session/api/useConfirmEmail.tsx b/client/src/entities/session/api/useConfirmEmail.tsx index dfd201961..9bd9e6507 100644 --- a/client/src/entities/session/api/useConfirmEmail.tsx +++ b/client/src/entities/session/api/useConfirmEmail.tsx @@ -2,24 +2,21 @@ import { useMutation } from '@tanstack/react-query'; import { API } from '@/shared/api'; import { IConfirmEmail, ILoginResponse } from '@teameights/types'; import { toast } from 'sonner'; -import { API_EMAIL_CONFIRM, DEFAULT, ONBOARDING } from '@/shared/constant'; +import { API_EMAIL_CONFIRM, DEFAULT, LOGIN } from '@/shared/constant'; import { useRouter } from 'next/navigation'; -import Cookies from 'js-cookie'; export const useConfirmEmail = () => { const router = useRouter(); return useMutation({ mutationFn: async (data: IConfirmEmail) => await API.post(API_EMAIL_CONFIRM, data), - onSuccess: data => { - localStorage.setItem('token', data.data.token); - Cookies.set('refreshToken', data.data.refreshToken); - - router.push(ONBOARDING); + onSuccess: () => { + toast.success('Successful confirmation! Log into your account.'); + router.push(LOGIN); }, onError: () => { - router.push(DEFAULT); toast.error('Invalid email confirmation'); + router.push(DEFAULT); }, }); }; diff --git a/client/src/shared/constant/client-routes.ts b/client/src/shared/constant/client-routes.ts index 6ce1bf962..8a97b4223 100644 --- a/client/src/shared/constant/client-routes.ts +++ b/client/src/shared/constant/client-routes.ts @@ -6,3 +6,5 @@ export const PASSWORD_SUCCESS = '/password/success'; export const PASSWORD_EXPIRED = '/password/expired'; export const SIGNUP_CONFIRMATION = '/signup/confirmation'; + +export const LOGIN = '/login'; diff --git a/server/.nvmrc b/server/.nvmrc new file mode 100644 index 000000000..805b5a4e0 --- /dev/null +++ b/server/.nvmrc @@ -0,0 +1 @@ +v20.9.0 diff --git a/server/docs/database.md b/server/docs/database.md index 481639351..6c5cd4c12 100644 --- a/server/docs/database.md +++ b/server/docs/database.md @@ -14,6 +14,7 @@ We use [TypeORM](https://www.npmjs.com/package/typeorm) and [PostgreSQL](https:/ - [Seeding](#seeding) - [Creating seeds](#creating-seeds) - [Run seed](#run-seed) + - [Factory and Faker](#factory-and-faker) - [Performance optimization](#performance-optimization) - [Indexes and Foreign Keys](#indexes-and-foreign-keys) - [Max connections](#max-connections) @@ -92,6 +93,104 @@ yarn seed:run --- +### Factory and Faker + +1. Install faker: + + ```bash + yarn add --save-dev @faker-js/faker + ``` +1. Create `src/libs/database/seeds/user/user.factory.ts`: + ```ts + import { faker } from '@faker-js/faker'; + import { RoleEnum } from 'src/roles/roles.enum'; + import { StatusEnum } from 'src/statuses/statuses.enum'; + import { Injectable } from '@nestjs/common'; + import { InjectRepository } from '@nestjs/typeorm'; + import { Repository } from 'typeorm'; + import { Role } from 'src/roles/entities/role.entity'; + import { Status } from 'src/statuses/entities/status.entity'; + import { User } from 'src/users/entities/user.entity'; + + @Injectable() + export class UserFactory { + constructor( + @InjectRepository(User) + private repositoryUser: Repository, + @InjectRepository(Role) + private repositoryRole: Repository, + @InjectRepository(Status) + private repositoryStatus: Repository, + ) {} + + createRandomUser() { + // Need for saving "this" context + return () => { + return this.repositoryUser.create({ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + password: faker.internet.password(), + role: this.repositoryRole.create({ + id: RoleEnum.user, + name: 'User', + }), + status: this.repositoryStatus.create({ + id: StatusEnum.active, + name: 'Active', + }), + }); + }; + } + } + ``` +1. Make changes in `src/libs/database/seeds/user/user-seed.service.ts`: + ```ts + // Some code here... + import { UserFactory } from './user.factory'; + import { faker } from '@faker-js/faker'; + + @Injectable() + export class UserSeedService { + constructor( + // Some code here... + private userFactory: UserFactory, + ) {} + + async run() { + // Some code here... + + await this.repository.save( + faker.helpers.multiple(this.userFactory.createRandomUser(), { + count: 5, + }), + ); + } + } + ``` +1. Make changes in `src/libs/database/seeds/user/user-seed.module.ts`: + ```ts + import { Module } from '@nestjs/common'; + import { TypeOrmModule } from '@nestjs/typeorm'; + import { User } from 'src/users/entities/user.entity'; + import { UserSeedService } from './user-seed.service'; + import { UserFactory } from './user.factory'; + import { Role } from 'src/roles/entities/role.entity'; + import { Status } from 'src/statuses/entities/status.entity'; + + @Module({ + imports: [TypeOrmModule.forFeature([User, Role, Status])], + providers: [UserSeedService, UserFactory], + exports: [UserSeedService, UserFactory], + }) + export class UserSeedModule {} + + ``` +1. Run seed: + ```bash + npm run seed:run + ``` + ## Performance optimization ### Indexes and Foreign Keys diff --git a/server/package.json b/server/package.json index b2fda0320..be0326ae9 100644 --- a/server/package.json +++ b/server/package.json @@ -61,7 +61,6 @@ "passport-anonymous": "1.0.1", "passport-jwt": "4.0.1", "pg": "8.11.3", - "qs": "^6.11.2", "reflect-metadata": "0.1.13", "rimraf": "5.0.1", "rxjs": "7.8.1", @@ -96,7 +95,7 @@ "hygen": "6.2.11", "is-ci": "3.0.1", "jest": "29.7.0", - "prettier": "3.0.3", + "prettier": "^3.1.0", "supertest": "6.3.3", "ts-jest": "29.1.1", "ts-loader": "9.4.4", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 2d779860d..42b5e005c 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -15,7 +15,6 @@ import { AuthGoogleModule } from './modules/auth/auth-google/auth-google.module' import { I18nModule } from 'nestjs-i18n/dist/i18n.module'; import { HeaderResolver } from 'nestjs-i18n'; import { TypeOrmConfigService } from './libs/database/typeorm-config.service'; -import { ForgotModule } from './modules/forgot/forgot.module'; import { MailModule } from './modules/mail/mail.module'; import { HomeModule } from './modules/home/home.module'; import { DataSource, DataSourceOptions } from 'typeorm'; @@ -73,7 +72,6 @@ import githubConfig from './config/github.config'; FilesModule, AuthModule, AuthGoogleModule, - ForgotModule, SessionModule, MailModule, MailerModule, diff --git a/server/src/config/app.config.ts b/server/src/config/app.config.ts index d50cd63c2..7f9bbe9b6 100644 --- a/server/src/config/app.config.ts +++ b/server/src/config/app.config.ts @@ -53,8 +53,8 @@ export default registerAs('app', () => { port: process.env.APP_PORT ? parseInt(process.env.APP_PORT, 10) : process.env.PORT - ? parseInt(process.env.PORT, 10) - : 3001, + ? parseInt(process.env.PORT, 10) + : 3001, apiPrefix: process.env.API_PREFIX || 'api', fallbackLanguage: process.env.APP_FALLBACK_LANGUAGE || 'en', headerLanguage: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang', diff --git a/server/src/config/auth.config.ts b/server/src/config/auth.config.ts index 50c0ae2e8..398c84596 100644 --- a/server/src/config/auth.config.ts +++ b/server/src/config/auth.config.ts @@ -15,6 +15,18 @@ class EnvironmentVariablesValidator { @IsString() AUTH_REFRESH_TOKEN_EXPIRES_IN: string; + + @IsString() + AUTH_FORGOT_SECRET: string; + + @IsString() + AUTH_FORGOT_TOKEN_EXPIRES_IN: string; + + @IsString() + AUTH_CONFIRM_EMAIL_SECRET: string; + + @IsString() + AUTH_CONFIRM_EMAIL_TOKEN_EXPIRES_IN: string; } export default registerAs('auth', () => { @@ -25,5 +37,9 @@ export default registerAs('auth', () => { expires: process.env.AUTH_JWT_TOKEN_EXPIRES_IN, refreshSecret: process.env.AUTH_REFRESH_SECRET, refreshExpires: process.env.AUTH_REFRESH_TOKEN_EXPIRES_IN, + forgotSecret: process.env.AUTH_FORGOT_SECRET, + forgotExpires: process.env.AUTH_FORGOT_TOKEN_EXPIRES_IN, + confirmEmailSecret: process.env.AUTH_CONFIRM_EMAIL_SECRET, + confirmEmailExpires: process.env.AUTH_CONFIRM_EMAIL_TOKEN_EXPIRES_IN, }; }); diff --git a/server/src/config/config.type.ts b/server/src/config/config.type.ts index 6a54e3d20..03654865d 100644 --- a/server/src/config/config.type.ts +++ b/server/src/config/config.type.ts @@ -15,6 +15,10 @@ export type AuthConfig = { expires?: string; refreshSecret?: string; refreshExpires?: string; + forgotSecret?: string; + forgotExpires?: string; + confirmEmailSecret?: string; + confirmEmailExpires?: string; }; export type DatabaseConfig = { diff --git a/server/src/libs/database/migrations/1701284493876-CreateUser.ts b/server/src/libs/database/migrations/1701598001777-CreateUser.ts similarity index 78% rename from server/src/libs/database/migrations/1701284493876-CreateUser.ts rename to server/src/libs/database/migrations/1701598001777-CreateUser.ts index 891b6c683..8fa98a887 100644 --- a/server/src/libs/database/migrations/1701284493876-CreateUser.ts +++ b/server/src/libs/database/migrations/1701598001777-CreateUser.ts @@ -1,64 +1,56 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class CreateUser1701284493876 implements MigrationInterface { - name = 'CreateUser1701284493876' +export class CreateUser1701598001777 implements MigrationInterface { + name = 'CreateUser1701598001777' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TABLE "role" ("id" integer NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_b36bcfe02fc8de3c57a8b2391c2" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "status" ("id" integer NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_e12743a7086ec826733f54e1d95" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "file" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "path" character varying NOT NULL, CONSTRAINT "PK_36b46d232307066b3a2c9ea3a1d" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "universities" ("id" SERIAL NOT NULL, "university" character varying NOT NULL, "degree" character varying NOT NULL, "major" character varying NOT NULL, "admissionDate" date NOT NULL, "graduationDate" date, "userId" integer, CONSTRAINT "PK_8da52f2cee6b407559fdbabf59e" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "jobs" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "company" character varying NOT NULL, "startDate" date NOT NULL, "endDate" date, "userId" integer, CONSTRAINT "PK_cf0a6c42b72fcc7f7c237def345" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "projects" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "link" character varying NOT NULL, "userId" integer, CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "links" ("id" SERIAL NOT NULL, "github" character varying, "linkedIn" character varying, "behance" character varying, "telegram" character varying, CONSTRAINT "PK_ecf17f4a741d3c5ba0b4c5ab4b6" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "skills" ("id" SERIAL NOT NULL, "designerTools" text array, "projectManagerTools" text array, "fields" text array, "programmingLanguages" text array, "frameworks" text array, "methodologies" text array, CONSTRAINT "PK_0d3212120f4ecedf90864d7e298" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying, "password" character varying, "username" character varying, "provider" character varying NOT NULL DEFAULT 'email', "socialId" character varying, "fullName" character varying, "hash" character varying, "isLeader" boolean, "country" character varying, "dateOfBirth" date, "speciality" character varying, "description" character varying, "experience" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "photoId" uuid, "roleId" integer, "statusId" integer, "skillsId" integer, "linksId" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "REL_0e51e612eb9ed2fa5ac4f44c7e" UNIQUE ("skillsId"), CONSTRAINT "REL_c5a79824fd8a241f5a7ec428b3" UNIQUE ("linksId"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying, "password" character varying, "username" character varying, "provider" character varying NOT NULL DEFAULT 'email', "socialId" character varying, "fullName" character varying, "isLeader" boolean, "country" character varying, "dateOfBirth" date, "speciality" character varying, "description" character varying, "experience" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "photoId" uuid, "roleId" integer, "statusId" integer, "skillsId" integer, "linksId" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "REL_0e51e612eb9ed2fa5ac4f44c7e" UNIQUE ("skillsId"), CONSTRAINT "REL_c5a79824fd8a241f5a7ec428b3" UNIQUE ("linksId"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE INDEX "IDX_9bd2fe7a8e694dedc4ec2f666f" ON "user" ("socialId") `); await queryRunner.query(`CREATE INDEX "IDX_035190f70c9aff0ef331258d28" ON "user" ("fullName") `); - await queryRunner.query(`CREATE INDEX "IDX_e282acb94d2e3aec10f480e4f6" ON "user" ("hash") `); await queryRunner.query(`CREATE INDEX "IDX_8bceb9ec5c48c54f7a3f11f31b" ON "user" ("isLeader") `); await queryRunner.query(`CREATE INDEX "IDX_5cb2b3e0419a73a360d327d497" ON "user" ("country") `); + await queryRunner.query(`CREATE TABLE "jobs" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "company" character varying NOT NULL, "startDate" date NOT NULL, "endDate" date, "userId" integer, CONSTRAINT "PK_cf0a6c42b72fcc7f7c237def345" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE "session" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "userId" integer, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE INDEX "IDX_3d2f174ef04fb312fdebd0ddc5" ON "session" ("userId") `); - await queryRunner.query(`CREATE TABLE "forgot" ("id" SERIAL NOT NULL, "hash" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "userId" integer, CONSTRAINT "PK_087959f5bb89da4ce3d763eab75" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_df507d27b0fb20cd5f7bef9b9a" ON "forgot" ("hash") `); await queryRunner.query(`ALTER TABLE "universities" ADD CONSTRAINT "FK_a8ad75b47a153c0d91f8360c9fb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "jobs" ADD CONSTRAINT "FK_79ae682707059d5f7655db4212a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "projects" ADD CONSTRAINT "FK_361a53ae58ef7034adc3c06f09f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_75e2be4ce11d447ef43be0e374f" FOREIGN KEY ("photoId") REFERENCES "file"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_c28e52f758e7bbc53828db92194" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_dc18daa696860586ba4667a9d31" FOREIGN KEY ("statusId") REFERENCES "status"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_0e51e612eb9ed2fa5ac4f44c7e1" FOREIGN KEY ("skillsId") REFERENCES "skills"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_c5a79824fd8a241f5a7ec428b3e" FOREIGN KEY ("linksId") REFERENCES "links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "jobs" ADD CONSTRAINT "FK_79ae682707059d5f7655db4212a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "session" ADD CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "forgot" ADD CONSTRAINT "FK_31f3c80de0525250f31e23a9b83" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "forgot" DROP CONSTRAINT "FK_31f3c80de0525250f31e23a9b83"`); await queryRunner.query(`ALTER TABLE "session" DROP CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53"`); + await queryRunner.query(`ALTER TABLE "jobs" DROP CONSTRAINT "FK_79ae682707059d5f7655db4212a"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_c5a79824fd8a241f5a7ec428b3e"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_0e51e612eb9ed2fa5ac4f44c7e1"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_dc18daa696860586ba4667a9d31"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_c28e52f758e7bbc53828db92194"`); await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_75e2be4ce11d447ef43be0e374f"`); await queryRunner.query(`ALTER TABLE "projects" DROP CONSTRAINT "FK_361a53ae58ef7034adc3c06f09f"`); - await queryRunner.query(`ALTER TABLE "jobs" DROP CONSTRAINT "FK_79ae682707059d5f7655db4212a"`); await queryRunner.query(`ALTER TABLE "universities" DROP CONSTRAINT "FK_a8ad75b47a153c0d91f8360c9fb"`); - await queryRunner.query(`DROP INDEX "public"."IDX_df507d27b0fb20cd5f7bef9b9a"`); - await queryRunner.query(`DROP TABLE "forgot"`); await queryRunner.query(`DROP INDEX "public"."IDX_3d2f174ef04fb312fdebd0ddc5"`); await queryRunner.query(`DROP TABLE "session"`); + await queryRunner.query(`DROP TABLE "jobs"`); await queryRunner.query(`DROP INDEX "public"."IDX_5cb2b3e0419a73a360d327d497"`); await queryRunner.query(`DROP INDEX "public"."IDX_8bceb9ec5c48c54f7a3f11f31b"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e282acb94d2e3aec10f480e4f6"`); await queryRunner.query(`DROP INDEX "public"."IDX_035190f70c9aff0ef331258d28"`); await queryRunner.query(`DROP INDEX "public"."IDX_9bd2fe7a8e694dedc4ec2f666f"`); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`DROP TABLE "skills"`); await queryRunner.query(`DROP TABLE "links"`); await queryRunner.query(`DROP TABLE "projects"`); - await queryRunner.query(`DROP TABLE "jobs"`); await queryRunner.query(`DROP TABLE "universities"`); await queryRunner.query(`DROP TABLE "file"`); await queryRunner.query(`DROP TABLE "status"`); diff --git a/server/src/modules/auth/auth-github/auth-github.controller.ts b/server/src/modules/auth/auth-github/auth-github.controller.ts index b6ff293e9..3198aac58 100644 --- a/server/src/modules/auth/auth-github/auth-github.controller.ts +++ b/server/src/modules/auth/auth-github/auth-github.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, SerializeOptions } from '@nestjs/common'; import { AuthService } from 'src/modules/auth/base/auth.service'; import { LoginResponseType } from 'src/modules/auth/base/types/login-response.type'; import { AuthGithubService } from './auth-github.service'; @@ -16,6 +16,9 @@ export class AuthGithubController { private readonly authGithubService: AuthGithubService ) {} + @SerializeOptions({ + groups: ['me'], + }) @Post('login') @HttpCode(HttpStatus.OK) async login(@Body() loginDto: AuthGithubLoginDto): Promise { diff --git a/server/src/modules/auth/auth-google/auth-google.controller.ts b/server/src/modules/auth/auth-google/auth-google.controller.ts index a6e3f6fd8..258b5d5b5 100644 --- a/server/src/modules/auth/auth-google/auth-google.controller.ts +++ b/server/src/modules/auth/auth-google/auth-google.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, SerializeOptions } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthService } from 'src/modules/auth/base/auth.service'; import { AuthGoogleService } from './auth-google.service'; @@ -16,6 +16,9 @@ export class AuthGoogleController { private readonly authGoogleService: AuthGoogleService ) {} + @SerializeOptions({ + groups: ['me'], + }) @Post('login') @HttpCode(HttpStatus.OK) async login(@Body() loginDto: AuthGoogleLoginDto): Promise { diff --git a/server/src/modules/auth/base/auth.controller.ts b/server/src/modules/auth/base/auth.controller.ts index 96033db22..dd1a019a8 100644 --- a/server/src/modules/auth/base/auth.controller.ts +++ b/server/src/modules/auth/base/auth.controller.ts @@ -57,8 +57,8 @@ export class AuthController { } @Post('email/confirm') - @HttpCode(HttpStatus.OK) - async confirmEmail(@Body() confirmEmailDto: AuthConfirmEmailDto): Promise { + @HttpCode(HttpStatus.NO_CONTENT) + async confirmEmail(@Body() confirmEmailDto: AuthConfirmEmailDto): Promise { return this.service.confirmEmail(confirmEmailDto.hash); } diff --git a/server/src/modules/auth/base/auth.module.ts b/server/src/modules/auth/base/auth.module.ts index 794d43a0b..17785a2ff 100644 --- a/server/src/modules/auth/base/auth.module.ts +++ b/server/src/modules/auth/base/auth.module.ts @@ -6,7 +6,6 @@ import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './strategies/jwt.strategy'; import { AnonymousStrategy } from './strategies/anonymous.strategy'; import { UsersModule } from 'src/modules/users/users.module'; -import { ForgotModule } from 'src/modules/forgot/forgot.module'; import { MailModule } from 'src/modules/mail/mail.module'; import { IsExist } from 'src/utils/validators/is-exists.validator'; import { IsNotExist } from 'src/utils/validators/is-not-exists.validator'; @@ -14,14 +13,7 @@ import { SessionModule } from 'src/modules/session/session.module'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; @Module({ - imports: [ - UsersModule, - ForgotModule, - SessionModule, - PassportModule, - MailModule, - JwtModule.register({}), - ], + imports: [UsersModule, SessionModule, PassportModule, MailModule, JwtModule.register({})], controllers: [AuthController], providers: [IsExist, IsNotExist, AuthService, JwtStrategy, JwtRefreshStrategy, AnonymousStrategy], exports: [AuthService], diff --git a/server/src/modules/auth/base/auth.service.ts b/server/src/modules/auth/base/auth.service.ts index 891956bc8..9b0c176cc 100644 --- a/server/src/modules/auth/base/auth.service.ts +++ b/server/src/modules/auth/base/auth.service.ts @@ -5,10 +5,8 @@ import { User } from 'src/modules/users/entities/user.entity'; import bcrypt from 'bcryptjs'; import { AuthEmailLoginDto } from './dto/auth-email-login.dto'; import { AuthUpdateDto } from './dto/auth-update.dto'; -import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import { RoleEnum } from 'src/libs/database/metadata/roles/roles.enum'; import { StatusEnum } from 'src/libs/database/metadata/statuses/statuses.enum'; -import crypto from 'crypto'; import { plainToClass } from 'class-transformer'; import { Status } from 'src/libs/database/metadata/statuses/entities/status.entity'; import { Role } from 'src/libs/database/metadata/roles/entities/role.entity'; @@ -16,7 +14,6 @@ import { AuthProvidersEnum } from 'src/modules/auth/auth-providers.enum'; import { SocialInterface } from 'src/libs/database/metadata/social/interfaces/social.interface'; import { AuthRegisterLoginDto } from './dto/auth-register-login.dto'; import { UsersService } from 'src/modules/users/users.service'; -import { ForgotService } from 'src/modules/forgot/forgot.service'; import { MailService } from 'src/modules/mail/mail.service'; import { NullableType } from 'src/utils/types/nullable.type'; import { LoginResponseType } from './types/login-response.type'; @@ -32,7 +29,6 @@ export class AuthService { constructor( private jwtService: JwtService, private usersService: UsersService, - private forgotService: ForgotService, private sessionService: SessionService, private mailService: MailService, private configService: ConfigService @@ -106,20 +102,23 @@ export class AuthService { authProvider: string, socialData: SocialInterface ): Promise { - let user: NullableType; + let user: NullableType = null; const socialEmail = socialData.email?.toLowerCase(); + let userByEmail: NullableType = null; // issue: https://github.com/typeorm/typeorm/issues/9316 - const userByEmail = socialEmail - ? await this.usersService.findOne({ - email: socialEmail, - }) - : null; - - user = await this.usersService.findOne({ - socialId: socialData.id, - provider: authProvider, - }); + if (socialEmail) { + userByEmail = await this.usersService.findOne({ + email: socialEmail, + }); + } + + if (socialData.id) { + user = await this.usersService.findOne({ + socialId: socialData.id, + provider: authProvider, + }); + } if (user) { if (socialEmail && !userByEmail) { @@ -190,9 +189,7 @@ export class AuthService { } async register(dto: AuthRegisterLoginDto): Promise { - const hash = crypto.createHash('sha256').update(randomStringGenerator()).digest('hex'); - - await this.usersService.create({ + const user = await this.usersService.create({ ...dto, email: dto.email, role: { @@ -201,9 +198,22 @@ export class AuthService { status: { id: StatusEnum.inactive, } as Status, - hash, }); + const hash = await this.jwtService.signAsync( + { + confirmEmailUserId: user.id, + }, + { + secret: this.configService.getOrThrow('auth.confirmEmailSecret', { + infer: true, + }), + expiresIn: this.configService.getOrThrow('auth.confirmEmailExpires', { + infer: true, + }), + } + ); + await this.mailService.userSignUp({ to: dto.email, data: { @@ -212,12 +222,36 @@ export class AuthService { }); } - async confirmEmail(hash: string): Promise { + async confirmEmail(hash: string): Promise { + let userId: User['id']; + + try { + const jwtData = await this.jwtService.verifyAsync<{ + confirmEmailUserId: User['id']; + }>(hash, { + secret: this.configService.getOrThrow('auth.confirmEmailSecret', { + infer: true, + }), + }); + + userId = jwtData.confirmEmailUserId; + } catch { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + hash: `invalidHash`, + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + const user = await this.usersService.findOne({ - hash, + id: userId, }); - if (!user) { + if (!user || user?.status?.id !== StatusEnum.inactive) { throw new HttpException( { status: HttpStatus.NOT_FOUND, @@ -227,32 +261,10 @@ export class AuthService { ); } - user.hash = null; user.status = plainToClass(Status, { id: StatusEnum.active, }); await user.save(); - - const session = await this.sessionService.create({ - user, - }); - - const { - token: jwtToken, - refreshToken, - tokenExpires, - } = await this.getTokensData({ - id: user.id, - role: user.role, - sessionId: session.id, - }); - - return { - refreshToken, - token: jwtToken, - tokenExpires, - user, - }; } async forgotPassword(email: string): Promise { @@ -272,11 +284,19 @@ export class AuthService { ); } - const hash = crypto.createHash('sha256').update(randomStringGenerator()).digest('hex'); - await this.forgotService.create({ - hash, - user, - }); + const hash = await this.jwtService.signAsync( + { + forgotUserId: user.id, + }, + { + secret: this.configService.getOrThrow('auth.forgotSecret', { + infer: true, + }), + expiresIn: this.configService.getOrThrow('auth.forgotExpires', { + infer: true, + }), + } + ); await this.mailService.forgotPassword({ to: email, @@ -287,13 +307,35 @@ export class AuthService { } async resetPassword(hash: string, password: string): Promise { - const forgot = await this.forgotService.findOne({ - where: { - hash, - }, + let userId: User['id']; + + try { + const jwtData = await this.jwtService.verifyAsync<{ + forgotUserId: User['id']; + }>(hash, { + secret: this.configService.getOrThrow('auth.forgotSecret', { + infer: true, + }), + }); + + userId = jwtData.forgotUserId; + } catch { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + hash: `invalidHash`, + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + const user = await this.usersService.findOne({ + id: userId, }); - if (!forgot) { + if (!user) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, @@ -305,7 +347,6 @@ export class AuthService { ); } - const user = forgot.user; user.password = password; await this.sessionService.softDelete({ @@ -314,7 +355,6 @@ export class AuthService { }, }); await user.save(); - await this.forgotService.softDelete(forgot.id); } async me(userJwtPayload: JwtPayloadType): Promise> { diff --git a/server/src/modules/auth/base/strategies/jwt-refresh.strategy.ts b/server/src/modules/auth/base/strategies/jwt-refresh.strategy.ts index 9652bb708..581a4ad57 100644 --- a/server/src/modules/auth/base/strategies/jwt-refresh.strategy.ts +++ b/server/src/modules/auth/base/strategies/jwt-refresh.strategy.ts @@ -8,7 +8,7 @@ import { AllConfigType } from 'src/config/config.type'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { - constructor(private configService: ConfigService) { + constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: configService.get('auth.refreshSecret', { infer: true }), diff --git a/server/src/modules/auth/base/strategies/jwt.strategy.ts b/server/src/modules/auth/base/strategies/jwt.strategy.ts index 2270f7508..69e7f0cb3 100644 --- a/server/src/modules/auth/base/strategies/jwt.strategy.ts +++ b/server/src/modules/auth/base/strategies/jwt.strategy.ts @@ -8,7 +8,7 @@ import { JwtPayloadType } from './types/jwt-payload.type'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor(private configService: ConfigService) { + constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: configService.get('auth.secret', { infer: true }), diff --git a/server/src/modules/forgot/entities/forgot.entity.ts b/server/src/modules/forgot/entities/forgot.entity.ts deleted file mode 100644 index 85f023866..000000000 --- a/server/src/modules/forgot/entities/forgot.entity.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - ManyToOne, - PrimaryGeneratedColumn, - DeleteDateColumn, -} from 'typeorm'; -import { User } from 'src/modules/users/entities/user.entity'; -import { Allow } from 'class-validator'; -import { EntityHelper } from 'src/utils/entity-helper'; - -@Entity() -export class Forgot extends EntityHelper { - @PrimaryGeneratedColumn() - id: number; - - @Allow() - @Column() - @Index() - hash: string; - - @Allow() - @ManyToOne(() => User, { - eager: true, - }) - user: User; - - @CreateDateColumn() - createdAt: Date; - - @DeleteDateColumn() - deletedAt: Date; -} diff --git a/server/src/modules/forgot/forgot.module.ts b/server/src/modules/forgot/forgot.module.ts deleted file mode 100644 index e2d6d99a8..000000000 --- a/server/src/modules/forgot/forgot.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Forgot } from './entities/forgot.entity'; -import { ForgotService } from './forgot.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([Forgot])], - providers: [ForgotService], - exports: [ForgotService], -}) -export class ForgotModule {} diff --git a/server/src/modules/forgot/forgot.service.ts b/server/src/modules/forgot/forgot.service.ts deleted file mode 100644 index 9d50d9f5f..000000000 --- a/server/src/modules/forgot/forgot.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptions } from 'src/utils/types/find-options.type'; -import { DeepPartial, Repository } from 'typeorm'; -import { Forgot } from './entities/forgot.entity'; -import { NullableType } from 'src/utils/types/nullable.type'; - -@Injectable() -export class ForgotService { - constructor( - @InjectRepository(Forgot) - private readonly forgotRepository: Repository - ) {} - - async findOne(options: FindOptions): Promise> { - return this.forgotRepository.findOne({ - where: options.where, - }); - } - - async findMany(options: FindOptions): Promise { - return this.forgotRepository.find({ - where: options.where, - }); - } - - async create(data: DeepPartial): Promise { - return this.forgotRepository.save(this.forgotRepository.create(data)); - } - - async softDelete(id: Forgot['id']): Promise { - await this.forgotRepository.softDelete(id); - } -} diff --git a/server/src/modules/mail/mail.service.ts b/server/src/modules/mail/mail.service.ts index 7a0ccb488..5361d843e 100644 --- a/server/src/modules/mail/mail.service.ts +++ b/server/src/modules/mail/mail.service.ts @@ -30,12 +30,17 @@ export class MailService { ]); } + const url = new URL( + this.configService.getOrThrow('app.frontendDomain', { + infer: true, + }) + '/proxy/email' + ); + url.searchParams.set('hash', mailData.data.hash); + await this.mailerService.sendMail({ to: mailData.to, subject: emailConfirmTitle, - text: `${this.configService.get('app.frontendDomain', { - infer: true, - })}/proxy/email?hash=${mailData.data.hash} ${emailConfirmTitle}`, + text: `${url.toString()} ${emailConfirmTitle}`, templatePath: path.join( this.configService.getOrThrow('app.workingDirectory', { infer: true, @@ -48,9 +53,7 @@ export class MailService { ), context: { title: emailConfirmTitle, - url: `${this.configService.get('app.frontendDomain', { - infer: true, - })}/proxy/email?hash=${mailData.data.hash}`, + url: url.toString(), actionTitle: emailConfirmTitle, app_name: this.configService.get('app.name', { infer: true }), text1, @@ -78,12 +81,17 @@ export class MailService { ]); } + const url = new URL( + this.configService.getOrThrow('app.frontendDomain', { + infer: true, + }) + '/password/update' + ); + url.searchParams.set('hash', mailData.data.hash); + await this.mailerService.sendMail({ to: mailData.to, subject: resetPasswordTitle, - text: `${this.configService.get('app.frontendDomain', { - infer: true, - })}/password/update?hash=${mailData.data.hash} ${resetPasswordTitle}`, + text: `${url.toString()} ${resetPasswordTitle}`, templatePath: path.join( this.configService.getOrThrow('app.workingDirectory', { infer: true, @@ -96,9 +104,7 @@ export class MailService { ), context: { title: resetPasswordTitle, - url: `${this.configService.get('app.frontendDomain', { - infer: true, - })}/password/update?hash=${mailData.data.hash}`, + url: url.toString(), actionTitle: resetPasswordTitle, app_name: this.configService.get('app.name', { infer: true, diff --git a/server/src/modules/users/dto/find-user.dto.ts b/server/src/modules/users/dto/find-user.dto.ts deleted file mode 100644 index 2a0d69551..000000000 --- a/server/src/modules/users/dto/find-user.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { ArrayNotEmpty, IsIn, IsNotEmpty, IsOptional } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { lowerCaseTransformer } from '../../../utils/transformers/lower-case.transformer'; -import { Speciality, specialityValues } from '../../../utils/types/specialities.type'; -import { Experience, experienceValues } from '../../../utils/types/experiences.type'; - -export class FindUserDto { - @ApiProperty() - @IsOptional() - @IsNotEmpty({ message: 'mustBeNotEmpty' }) - fullName?: string; - - @ApiProperty({ example: 'nmashchenko' }) - @Transform(lowerCaseTransformer) - @IsNotEmpty() - @IsOptional() - username?: string; - - @ApiProperty() - @IsOptional() - @IsNotEmpty({ message: 'mustBeNotEmpty' }) - isLeader?: boolean; - - @ApiProperty() - @IsOptional() - @IsNotEmpty({ message: 'mustBeNotEmpty' }) - country?: string; - - @ApiProperty({ enum: specialityValues }) - @IsOptional() - @IsNotEmpty({ message: 'mustBeNotEmpty' }) - @IsIn(specialityValues, { message: 'Must be valid speciality type!' }) - speciality?: Speciality; - - @ApiProperty({ enum: experienceValues }) - @IsOptional() - @IsNotEmpty({ message: 'mustBeNotEmpty' }) - @IsIn(experienceValues, { message: 'Must be valid experience type!' }) - experience?: Experience; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - programmingLanguages?: string[]; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - frameworks?: string[]; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - projectManagerTools?: string[]; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - designerTools?: string[]; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - fields?: string[]; - - @ApiProperty() - @IsOptional() - @ArrayNotEmpty() - methodologies?: string[]; -} diff --git a/server/src/modules/users/dto/query-user.dto.ts b/server/src/modules/users/dto/query-user.dto.ts new file mode 100644 index 000000000..06b8cd069 --- /dev/null +++ b/server/src/modules/users/dto/query-user.dto.ts @@ -0,0 +1,127 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { plainToInstance, Transform, Type } from 'class-transformer'; +import { lowerCaseTransformer } from '../../../utils/transformers/lower-case.transformer'; +import { Speciality, specialityValues } from '../../../utils/types/specialities.type'; +import { Experience, experienceValues } from '../../../utils/types/experiences.type'; +import { User } from '../entities/user.entity'; + +export class SortUserDto { + @ApiProperty() + @IsString() + orderBy: keyof User; + + @ApiProperty() + @IsString() + order: string; +} + +export class FilterUserDto { + @ApiProperty() + @IsOptional() + @IsNotEmpty({ message: 'mustBeNotEmpty' }) + fullName?: string; + + @ApiProperty({ example: 'nmashchenko' }) + @Transform(lowerCaseTransformer) + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiProperty() + @IsOptional() + @IsNotEmpty({ message: 'mustBeNotEmpty' }) + isLeader?: boolean; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + countries?: string[]; + + @ApiProperty({ + type: [String], + enum: specialityValues, + }) + @IsOptional() + @ArrayNotEmpty() + @IsIn(specialityValues, { each: true }) + specialities?: Speciality[]; + + @ApiProperty({ enum: experienceValues }) + @IsOptional() + @IsNotEmpty({ message: 'mustBeNotEmpty' }) + @IsIn(experienceValues) + experience?: Experience; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + programmingLanguages?: string[]; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + frameworks?: string[]; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + projectManagerTools?: string[]; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + designerTools?: string[]; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + fields?: string[]; + + @ApiProperty() + @IsOptional() + @ArrayNotEmpty() + methodologies?: string[]; +} + +export class QueryUserDto { + @ApiProperty({ + required: false, + }) + @Transform(({ value }) => (value ? Number(value) : 1)) + @IsNumber() + @IsOptional() + page: number; + + @ApiProperty({ + required: false, + }) + @Transform(({ value }) => (value ? Number(value) : 10)) + @IsNumber() + @IsOptional() + limit: number; + + @ApiProperty({ type: String, required: false }) + @IsOptional() + @Transform(({ value }) => (value ? plainToInstance(FilterUserDto, JSON.parse(value)) : undefined)) + @ValidateNested() + @Type(() => FilterUserDto) + filters?: FilterUserDto | null; + + @ApiProperty({ type: String, required: false }) + @IsOptional() + @Transform(({ value }) => { + return value ? plainToInstance(SortUserDto, JSON.parse(value)) : undefined; + }) + @ValidateNested({ each: true }) + @Type(() => SortUserDto) + sort?: SortUserDto[] | null; +} diff --git a/server/src/modules/users/entities/user.entity.ts b/server/src/modules/users/entities/user.entity.ts index b19b00488..8e0f52878 100644 --- a/server/src/modules/users/entities/user.entity.ts +++ b/server/src/modules/users/entities/user.entity.ts @@ -90,11 +90,6 @@ export class User extends EntityHelper { }) status?: Status; - @Column({ type: String, nullable: true }) - @Index() - @Exclude({ toPlainOnly: true }) - hash: string | null; - @Index() @Column({ type: Boolean, nullable: true }) isLeader?: boolean | null; diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index f08cce5bd..c51e2c471 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -8,8 +8,6 @@ import { Delete, UseGuards, Query, - DefaultValuePipe, - ParseIntPipe, HttpStatus, HttpCode, SerializeOptions, @@ -26,8 +24,7 @@ import { infinityPagination } from 'src/utils/infinity-pagination'; import { User } from './entities/user.entity'; import { InfinityPaginationResultType } from 'src/utils/types/infinity-pagination-result.type'; import { NullableType } from 'src/utils/types/nullable.type'; -import { parse } from 'qs'; -import { FindUserDto } from './dto/find-user.dto'; +import { QueryUserDto } from './dto/query-user.dto'; @ApiTags('Users') @Controller({ @@ -54,12 +51,9 @@ export class UsersController { }) @Get() @HttpCode(HttpStatus.OK) - async findAll( - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, - @Query('filters') filters?: string - ): Promise> { - const filterOptions: FindUserDto | undefined = filters ? parse(filters) : undefined; + async findAll(@Query() query: QueryUserDto): Promise> { + const page = query?.page ?? 1; + let limit = query?.limit ?? 10; if (limit > 50) { limit = 50; @@ -67,9 +61,12 @@ export class UsersController { return infinityPagination( await this.usersService.findManyWithPagination({ - page, - limit, - filters: filterOptions, + filterOptions: query?.filters, + sortOptions: query?.sort, + paginationOptions: { + page, + limit, + }, }), { page, limit } ); diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 1da9f472a..ab9fce314 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EntityCondition } from 'src/utils/types/entity-condition.type'; import { IPaginationOptions } from 'src/utils/types/pagination-options'; -import { ArrayOverlap, DeepPartial, Like, Repository } from 'typeorm'; +import { ArrayOverlap, DeepPartial, FindOptionsWhere, In, Like, Repository } from 'typeorm'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './entities/user.entity'; import { NullableType } from 'src/utils/types/nullable.type'; -import { FindUserDto } from './dto/find-user.dto'; +import { FilterUserDto, SortUserDto } from './dto/query-user.dto'; @Injectable() export class UsersService { @@ -19,35 +19,49 @@ export class UsersService { return this.usersRepository.save(this.usersRepository.create(createProfileDto)); } - async findManyWithPagination( - paginationOptions: IPaginationOptions - ): Promise { - const filters = paginationOptions.filters; + async findManyWithPagination({ + filterOptions, + sortOptions, + paginationOptions, + }: { + filterOptions?: FilterUserDto | null; + sortOptions?: SortUserDto[] | null; + paginationOptions: IPaginationOptions; + }): Promise { + const where: FindOptionsWhere = {}; + + if (filterOptions) { + where.fullName = filterOptions?.fullName && Like(`%${filterOptions.fullName}%`); + where.username = filterOptions?.username && Like(`%${filterOptions.username}%`); + where.isLeader = filterOptions?.isLeader && filterOptions.isLeader; + + where.country = filterOptions?.countries && In(filterOptions.countries); + where.speciality = filterOptions?.specialities && In(filterOptions.specialities); + where.experience = filterOptions?.experience && Like(`%${filterOptions.experience}%`); + + where.skills = { + programmingLanguages: + filterOptions?.programmingLanguages && ArrayOverlap(filterOptions.programmingLanguages), + frameworks: filterOptions?.frameworks && ArrayOverlap(filterOptions.frameworks), + designerTools: filterOptions?.designerTools && ArrayOverlap(filterOptions.designerTools), + projectManagerTools: + filterOptions?.projectManagerTools && ArrayOverlap(filterOptions.projectManagerTools), + fields: filterOptions?.fields && ArrayOverlap(filterOptions.fields), + methodologies: filterOptions?.methodologies && ArrayOverlap(filterOptions.methodologies), + }; + } return this.usersRepository.find({ skip: (paginationOptions.page - 1) * paginationOptions.limit, take: paginationOptions.limit, - where: { - fullName: filters?.fullName && Like(`%${filters.fullName}%`), - username: filters?.username && Like(`%${filters.username}%`), - isLeader: filters?.isLeader && filters.isLeader, - - // TODO: change for arrays (countries / specialities / experiences) - country: filters?.country && Like(`%${filters.country}%`), - speciality: filters?.speciality && Like(`%${filters.speciality}%`), - experience: filters?.experience && Like(`%${filters.experience}%`), - - skills: { - programmingLanguages: - filters?.programmingLanguages && ArrayOverlap(filters.programmingLanguages), - frameworks: filters?.frameworks && ArrayOverlap(filters.frameworks), - designerTools: filters?.designerTools && ArrayOverlap(filters.designerTools), - projectManagerTools: - filters?.projectManagerTools && ArrayOverlap(filters.projectManagerTools), - fields: filters?.fields && ArrayOverlap(filters.fields), - methodologies: filters?.methodologies && ArrayOverlap(filters.methodologies), - }, - }, + where: where, + order: sortOptions?.reduce( + (accumulator, sort) => ({ + ...accumulator, + [sort.orderBy]: sort.order, + }), + {} + ), }); } diff --git a/server/src/utils/infinity-pagination.ts b/server/src/utils/infinity-pagination.ts index 6a62899dc..f56f4c67a 100644 --- a/server/src/utils/infinity-pagination.ts +++ b/server/src/utils/infinity-pagination.ts @@ -3,7 +3,7 @@ import { InfinityPaginationResultType } from './types/infinity-pagination-result export const infinityPagination = ( data: T[], - options: IPaginationOptions + options: IPaginationOptions ): InfinityPaginationResultType => { return { data, diff --git a/server/src/utils/types/pagination-options.ts b/server/src/utils/types/pagination-options.ts index 43f44e5a7..7616a0b96 100644 --- a/server/src/utils/types/pagination-options.ts +++ b/server/src/utils/types/pagination-options.ts @@ -1,5 +1,4 @@ -export interface IPaginationOptions { +export interface IPaginationOptions { page: number; limit: number; - filters?: T; } diff --git a/server/src/utils/validation-options.ts b/server/src/utils/validation-options.ts index b865516dc..a8ca55b13 100644 --- a/server/src/utils/validation-options.ts +++ b/server/src/utils/validation-options.ts @@ -1,10 +1,32 @@ -import { HttpStatus, ValidationPipeOptions } from '@nestjs/common'; +import { HttpException, HttpStatus, ValidationError, ValidationPipeOptions } from '@nestjs/common'; + +function generateErrors(errors: ValidationError[]) { + return errors.reduce( + (accumulator, currentValue) => ({ + ...accumulator, + [currentValue.property]: + (currentValue.children?.length ?? 0) > 0 + ? generateErrors(currentValue.children ?? []) + : Object.values(currentValue.constraints ?? {}).join(', '), + }), + {} + ); +} const validationOptions: ValidationPipeOptions = { transform: true, whitelist: true, forbidNonWhitelisted: true, errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + exceptionFactory: (errors: ValidationError[]) => { + return new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: generateErrors(errors), + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + }, }; export default validationOptions; diff --git a/server/test/user/auth.e2e-spec.ts b/server/test/user/auth.e2e-spec.ts index 79316e6f8..f2af5c430 100644 --- a/server/test/user/auth.e2e-spec.ts +++ b/server/test/user/auth.e2e-spec.ts @@ -46,7 +46,7 @@ describe('Auth user (e2e)', () => { }) .expect(422) .expect(({ body }) => { - expect(body.error).toBeDefined(); + expect(body.errors.email).toBeDefined(); }); }); @@ -79,9 +79,9 @@ describe('Auth user (e2e)', () => { .find( letter => letter.to[0].address.toLowerCase() === newUserEmail.toLowerCase() && - /.*email\?hash\=(\w+).*/g.test(letter.text) + /.*email\?hash\=(\S+).*/g.test(letter.text) ) - ?.text.replace(/.*email\?hash\=(\w+).*/g, '$1') + ?.text.replace(/.*email\?hash\=(\S+).*/g, '$1') ); return request(app) @@ -89,7 +89,7 @@ describe('Auth user (e2e)', () => { .send({ hash, }) - .expect(200); + .expect(204); }); it('Can not confirm email with same link twice: /api/v1/auth/email/confirm (POST)', async () => { @@ -101,9 +101,9 @@ describe('Auth user (e2e)', () => { .find( letter => letter.to[0].address.toLowerCase() === newUserEmail.toLowerCase() && - /.*email\?hash\=(\w+).*/g.test(letter.text) + /.*email\?hash\=(\S+).*/g.test(letter.text) ) - ?.text.replace(/.*email\?hash\=(\w+).*/g, '$1') + ?.text.replace(/.*email\?hash\=(\S+).*/g, '$1') ); return request(app) diff --git a/server/test/user/users.e2e-spec.ts b/server/test/user/users.e2e-spec.ts index 12de5ff94..c5b7cc4c1 100644 --- a/server/test/user/users.e2e-spec.ts +++ b/server/test/user/users.e2e-spec.ts @@ -1,7 +1,6 @@ import request from 'supertest'; import { APP_URL } from '../utils/constants'; import { faker } from '@faker-js/faker'; -import qs from 'qs'; describe('Get users (e2e)', () => { const app = APP_URL; @@ -182,8 +181,8 @@ describe('Get users (e2e)', () => { .send({ skills: { programmingLanguages: programmingLanguages, - type: "developer" - } + type: 'developer', + }, }) .expect(200) .expect(({ body }) => { @@ -199,12 +198,12 @@ describe('Get users (e2e)', () => { .send({ skills: { programmingLanguages: programmingLanguages, - type: "designer" - } + type: 'designer', + }, }) .expect(422) .expect(({ body }) => { - expect(body.error).toBeDefined(); + expect(body.errors).toBeDefined(); }); }); // @@ -229,10 +228,10 @@ describe('Get users (e2e)', () => { type: 'bearer', }) .send({ - "skills": { - "frameworks": frameworks, - "type": "developer" - } + skills: { + frameworks: frameworks, + type: 'developer', + }, }) .expect(200) .expect(({ body }) => { @@ -252,7 +251,7 @@ describe('Get users (e2e)', () => { it('Get users with fullName filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5BfullName%5D=Slavik%20Ukraincev`) + .get(`/api/v1/users?filters={"fullName": "${fullName}"}`) .expect(200) .send() .expect(({ body }) => { @@ -264,7 +263,7 @@ describe('Get users (e2e)', () => { it('Get users with username filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5Busername%5D=${username}`) + .get(`/api/v1/users?filters={"username": "${username}"}`) .expect(200) .send() .expect(({ body }) => { @@ -276,7 +275,7 @@ describe('Get users (e2e)', () => { it('Get users with country filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5Bcountry%5D=${country}`) + .get(`/api/v1/users?filters={"countries": ["${country}"]}`) .expect(200) .send() .expect(({ body }) => { @@ -287,14 +286,8 @@ describe('Get users (e2e)', () => { }); it('Get users with speciality filter: /api/v1/users?filters= (GET)', () => { - const filters = { - filters: { - speciality: speciality, - }, - }; - return request(app) - .get(`/api/v1/users?${qs.stringify(filters)}`) + .get(`/api/v1/users?filters={"specialities": ["${speciality}"]}`) .expect(200) .send() .expect(({ body }) => { @@ -306,7 +299,7 @@ describe('Get users (e2e)', () => { it('Get users with experience filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5Bexperience%5D=${experience}`) + .get(`/api/v1/users?filters={"experience": "${experience}"}`) .expect(200) .send() .expect(({ body }) => { @@ -318,7 +311,7 @@ describe('Get users (e2e)', () => { it('Get users with programmingLanguages filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5BprogrammingLanguages%5D%5B0%5D=JS`) + .get(`/api/v1/users?filters={"programmingLanguages": ["JS"]}`) .expect(200) .send() .expect(({ body }) => { @@ -330,7 +323,7 @@ describe('Get users (e2e)', () => { it('Get users with frameworks filter: /api/v1/users?filters= (GET)', () => { return request(app) - .get(`/api/v1/users?filters%5Bframeworks%5D%5B0%5D=NestJS`) + .get(`/api/v1/users?filters={"frameworks": ["NestJS"]}`) .expect(200) .send() .expect(({ body }) => { diff --git a/server/yarn.lock b/server/yarn.lock index 8c5769df7..a70458269 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -9345,12 +9345,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:3.0.3": - version: 3.0.3 - resolution: "prettier@npm:3.0.3" +"prettier@npm:^3.1.0": + version: 3.1.0 + resolution: "prettier@npm:3.1.0" bin: prettier: bin/prettier.cjs - checksum: e10b9af02b281f6c617362ebd2571b1d7fc9fb8a3bd17e371754428cda992e5e8d8b7a046e8f7d3e2da1dcd21aa001e2e3c797402ebb6111b5cd19609dd228e0 + checksum: 44b556bd56f74d7410974fbb2418bb4e53a894d3e7b42f6f87779f69f27a6c272fa7fc27cec0118cd11730ef3246478052e002cbd87e9a253f9cd04a56aa7d9b languageName: node linkType: hard @@ -9435,7 +9435,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0, qs@npm:^6.11.2": +"qs@npm:^6.11.0": version: 6.11.2 resolution: "qs@npm:6.11.2" dependencies: @@ -10473,8 +10473,7 @@ __metadata: passport-anonymous: 1.0.1 passport-jwt: 4.0.1 pg: 8.11.3 - prettier: 3.0.3 - qs: ^6.11.2 + prettier: ^3.1.0 reflect-metadata: 0.1.13 rimraf: 5.0.1 rxjs: 7.8.1