From f590128548e767898b1c16f3a6aead2125714876 Mon Sep 17 00:00:00 2001 From: maxCastro1 Date: Wed, 1 May 2024 22:30:30 +0200 Subject: [PATCH] Implemented user registration feature with input validation, password hashing, and database integration --- .gitignore | 2 +- migrations/1714595134552-UserMigration.ts | 31 ++++++++++ package.json | 15 +++-- src/__test__/route.test.ts | 57 ++++++++++++++++--- src/controllers/authController.ts | 49 ++++++++++++++++ src/controllers/index.ts | 8 +-- src/entities/User.ts | 69 +++++++++++++++++++++++ src/index.ts | 4 +- src/middlewares/errorHandler.ts | 4 +- src/routes/UserRoutes.ts | 11 ++++ src/routes/index.ts | 11 ++-- tsconfig.json | 4 +- 12 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 migrations/1714595134552-UserMigration.ts create mode 100644 src/controllers/authController.ts create mode 100644 src/entities/User.ts create mode 100644 src/routes/UserRoutes.ts diff --git a/.gitignore b/.gitignore index 66612c2..1500c37 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ package-lock.json coverage/ dist /src/logs -.DS_Stor \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/migrations/1714595134552-UserMigration.ts b/migrations/1714595134552-UserMigration.ts new file mode 100644 index 0000000..05693f8 --- /dev/null +++ b/migrations/1714595134552-UserMigration.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserMigration1614495123940 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "user" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "firstName" character varying NOT NULL, + "lastName" character varying NOT NULL, + "email" character varying NOT NULL, + "password" character varying NOT NULL, + "gender" character varying NOT NULL, + "phoneNumber" character varying NOT NULL, + "photoUrl" character varying, + "verified" boolean NOT NULL, + "status" character varying NOT NULL CHECK (status IN ('active', 'suspended')), + "userType" character varying NOT NULL DEFAULT 'Buyer' CHECK (userType IN ('Admin', 'Buyer', 'Vendor')), + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), + CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "user"`); + } + +} \ No newline at end of file diff --git a/package.json b/package.json index 87ca304..84a5b11 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "start": "node dist/index.js", "lint": "eslint .", "lint:fix": "eslint --fix .", - "format": "prettier --write ." + "format": "prettier --write .", + "typeorm": "typeorm-ts-node-commonjs", + "migration": " npm run typeorm migration:run -- -d ./ormconfig.js" }, "keywords": [], "author": "Scrum master", @@ -19,11 +21,13 @@ "@types/express-winston": "^4.0.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", + "bcrypt": "^5.1.1", + "class-validator": "^0.14.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", - "highlight.js": "^11.9.0", "express-winston": "^4.2.0", + "highlight.js": "^11.9.0", "jsend": "^1.1.0", "morgan": "^1.10.0", "nodemon": "^3.1.0", @@ -42,6 +46,7 @@ }, "devDependencies": { "@eslint/js": "^9.1.1", + "@types/bcrypt": "^5.0.2", "@types/body-parser": "^1.19.5", "@types/cors": "^2.8.17", "@types/dotenv": "^8.2.0", @@ -50,8 +55,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/jsend": "^1.0.32", - "@types/node": "^20.12.7", "@types/morgan": "^1.9.9", + "@types/node": "^20.12.7", + "@types/reflect-metadata": "^0.1.0", "@types/supertest": "^6.0.2", "@types/winston": "^2.4.4", "@typescript-eslint/eslint-plugin": "^7.7.1", @@ -62,10 +68,11 @@ "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.6", "prettier": "^3.2.5", "supertest": "^7.0.0", "ts-jest": "^29.1.2", "typescript": "^5.4.5", "typescript-eslint": "^7.7.1" } -} +} \ No newline at end of file diff --git a/src/__test__/route.test.ts b/src/__test__/route.test.ts index 401f5b8..6b296a3 100644 --- a/src/__test__/route.test.ts +++ b/src/__test__/route.test.ts @@ -1,24 +1,67 @@ import request from 'supertest'; import { app, server } from '../index'; // update this with the path to your app file - import { createConnection, getConnection, getConnectionOptions } from 'typeorm'; +import { User } from '../entities/User'; +import { getRepository, Repository } from 'typeorm'; beforeAll(async () => { // Connect to the test database const connectionOptions = await getConnectionOptions(); await createConnection({ ...connectionOptions, name: 'testConnection' }); }); + afterAll(async () => { await getConnection('testConnection').close(); server.close(); }); -describe('GET /', () => { - // afterAll(done => { - // server.close(done); - // }); - it('responds with "Knights Ecommerce API"', done => { - request(app).get('/').expect(200, 'Knights Ecommerce API', done); + +describe('GET /', () => { + it('This is a testing route that returns', done => { + request(app) + .get('/api/v1/status') + .expect(200) + .expect('Content-Type', /json/) + .expect({ + status: 'success', + data: { + code: 202, + message: 'This is a testing route that returns: 202' + } + }, done); }); }); +describe('POST /user/register', () => { + it('should register a new user and then delete it', async () => { + // Arrange + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '1234567890', + userType: 'Buyer', + status: 'active', + verified: true, + photoUrl: 'https://example.com/photo.jpg', + }; + + // Act + const res = await request(app) + .post('/user/register') + .send(newUser); + + // Assert + expect(res.status).toBe(201); + expect(res.body).toEqual({ message: 'User registered successfully' }); + + // Clean up: delete the test user + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: newUser.email } }); + if (user) { + await userRepository.remove(user); + } + }); +}); \ No newline at end of file diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts new file mode 100644 index 0000000..209a350 --- /dev/null +++ b/src/controllers/authController.ts @@ -0,0 +1,49 @@ +import { Request, Response } from 'express'; +import { User } from '../entities/User';; +import bcrypt from 'bcrypt'; +import { getRepository } from 'typeorm'; + + +class UserController { + static registerUser = async (req: Request, res: Response) => { + const { firstName, lastName, email, password, gender, phoneNumber, userType, status, verified, photoUrl } = req.body; + + // Validate user input + if (!(firstName && lastName && email && password && gender && phoneNumber && verified && photoUrl)) { + return res.status(400).json({ error: 'Please fill all the fields' }); + } + + const userRepository = getRepository(User); + + + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + const existingUserNumber = await userRepository.findOneBy({ phoneNumber }); + + if (existingUser || existingUserNumber) { + return res.status(400).json({ error: 'Email or phone number already in use' }); + } + + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Create user + const user = new User(); + user.firstName = firstName; + user.lastName = lastName; + user.email = email; + user.password = hashedPassword; + user.userType = userType; + user.gender = gender; + user.phoneNumber = phoneNumber; + user.photoUrl = photoUrl; + user.status = status ? status : 'active'; + user.verified = verified; + + // Save user + await userRepository.save(user); + + return res.status(201).json({ message: 'User registered successfully' }); + }; +} +export { UserController }; \ No newline at end of file diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4ba41c9..554dd4e 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,5 +1,3 @@ -// export all controllers -function myFunction (): void { - console.log('Hello'); -} -myFunction(); +import { UserController } from './authController'; + +export{UserController}; \ No newline at end of file diff --git a/src/entities/User.ts b/src/entities/User.ts new file mode 100644 index 0000000..2d0557f --- /dev/null +++ b/src/entities/User.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + CreateDateColumn, + UpdateDateColumn, + } from 'typeorm'; + import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator'; + + @Entity() + @Unique(['email']) + export class User { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column() + @IsNotEmpty() + @IsString() + firstName!: string; + + @Column() + @IsNotEmpty() + @IsString() + lastName!: string; + + @Column() + @IsNotEmpty() + @IsEmail() + email!: string; + + @Column() + @IsNotEmpty() + password!: string; + + @Column() + @IsNotEmpty() + @IsString() + gender!: string; + + @Column() + @IsNotEmpty() + phoneNumber!: string; + + @Column({ nullable: true }) + photoUrl?: string; + + @Column() + @IsNotEmpty() + @IsBoolean() + verified!: boolean; + + @Column() + @IsNotEmpty() + @IsIn(['active', 'suspended']) + status!: 'active' | 'suspended'; + + @Column({ default: "Buyer" }) + @IsNotEmpty() + @IsIn(['Admin', 'Buyer', 'Vendor']) + userType!: 'Admin' | 'Buyer' | 'Vendor'; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 36b592e..55443f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import cors from 'cors'; import dotenv from 'dotenv'; import router from './routes'; import { addDocumentation } from './startups/docs'; +import 'reflect-metadata'; + import { CustomError, errorHandler } from './middlewares/errorHandler'; import morgan from 'morgan'; @@ -32,4 +34,4 @@ app.use(morgan(morganFormat)); export const server = app.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); -}); +}); \ No newline at end of file diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index e4e967f..8a24a54 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; class CustomError extends Error { statusCode: number; @@ -12,7 +12,7 @@ class CustomError extends Error { } } -const errorHandler = (err: CustomError, req: Request, res: Response) => { +const errorHandler = (err: CustomError, req: Request, res: Response,next: NextFunction) => { err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; res.status(err.statusCode).json({ diff --git a/src/routes/UserRoutes.ts b/src/routes/UserRoutes.ts new file mode 100644 index 0000000..734a565 --- /dev/null +++ b/src/routes/UserRoutes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { UserController } from '../controllers/index'; + + +const { registerUser } = UserController; + +const router = Router(); + +router.post('/register', registerUser); + +export default router; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 78cb97d..8d1a53a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,14 +1,13 @@ import { Request, Response, Router } from 'express'; +import userRoutes from './UserRoutes'; import { responseSuccess } from '../utils/response.utils'; + const router = Router(); -router.get("/", (req: Request, res: Response) => { - res.send("Knights Ecommerce API"); -}); + router.get('/api/v1/status', (req: Request, res: Response) => { - return responseSuccess(res, 202, 'This is a testing route that returns: 201'); + return responseSuccess(res, 202, 'This is a testing route that returns: 202'); }); -// All routes should be imported here and get export after specifying first route -// example router.use("/stock". stockRoutes) =>:: First import stockRoutes and use it here, This shows how the route export will be handled +router.use('/user', userRoutes); export default router; diff --git a/tsconfig.json b/tsconfig.json index 7d6a779..6e7652b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,8 @@ "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */