├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── Procfile ├── README.md ├── app.ts ├── controllers ├── admins.test.ts ├── admins.ts ├── auth.test.ts ├── auth.ts ├── categories.test.ts ├── categories.ts ├── customers.test.ts ├── customers.ts ├── orders.ts ├── products.test.ts └── products.ts ├── db ├── data.ts └── seed.ts ├── docker-compose.test.yml ├── docker-compose.yml ├── docs ├── favicon.png ├── haru-api.postman_collection.json └── index.html ├── jest.config.js ├── middlewares ├── asyncHandler.ts ├── authHandler.ts └── errorHandler.ts ├── package-lock.json ├── package.json ├── prisma ├── client.ts ├── migrations │ ├── 20210927132352_postgres_init │ │ └── migration.sql │ ├── 20210928175554_make_category_name_unique │ │ └── migration.sql │ ├── 20211006091835_added_admin_role │ │ └── migration.sql │ ├── 20211008155526_reset_pwd_expire │ │ └── migration.sql │ ├── 20211008161931_reset_pwd_expire │ │ └── migration.sql │ ├── 20220303144904_add_tags_for_products │ │ └── migration.sql │ ├── 20220305160533_updated_order_fields │ │ └── migration.sql │ ├── 20220307144514_updated_order_date_default_to_current_date │ │ └── migration.sql │ ├── 20220307154817_updated_order_address_to_shipping_address │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── singleton.ts ├── routers ├── admins.ts ├── auth.ts ├── categories.ts ├── customers.ts ├── orders.ts └── products.ts ├── server.ts ├── tsconfig.json └── utils ├── emailTemplate.ts ├── errorObject.ts ├── errorResponse.ts ├── errors.txt ├── extendedRequest.ts ├── helperFunctions.ts └── sendEmail.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | dist 4 | core -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | .env.test 4 | .env* 5 | dist/ 6 | *.env* 7 | core 8 | 9 | # MacDS 10 | .DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-slim 2 | RUN apt-get update 3 | RUN apt-get install -y openssl 4 | WORKDIR /app 5 | COPY package*.json ./ 6 | RUN npm install 7 | COPY . . 8 | EXPOSE $PORT 9 | CMD ["npm", "run", "devStart"] -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | 3 | release: npx prisma migrate deploy 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Haru-Fashion API 2 | 3 | RESTful API for Haru-Fashion e-commerce web application. Developed with NodeJS, Express, TypeScript, Prisma and PostgreSQL. 4 | 5 | ## Badges 6 | 7 |  8 | [](https://circleci.com/gh/satnaing/haru-api/tree/master) 9 | [](https://haru-fashion.herokuapp.com/) 10 | [](https://github.com/facebook/jest) 11 | [](https://github.com/prettier/prettier) 12 | 13 | ## Demo 14 | 15 | [🚀 API Demo](https://haru-fashion.herokuapp.com/api/v1/categories) 16 | [📖 API Docs](https://satnaing.github.io/haru-api/) 17 | 18 | ## Features 19 | 20 | Here are some of the project's features 21 | 22 | - CRUD Operations 23 | - Authentication 24 | - Authorization and RBAC 25 | - Forgot/Reset Password 26 | - Full-Text Search (for products) 27 | 28 | ## Tech Stack 29 | 30 | **Backend:** Node, Express, TypeScript 31 | **Database:** Prisma + PostgreSQL 32 | **Testing:** Jest 33 | **Containerization:** Docker 34 | **CI/CD:** CircleCI 35 | 36 | ## Running Locally 37 | 38 | Clone the project 39 | 40 | ```bash 41 | git clone https://github.com/softking0503/haru-api.git 42 | ``` 43 | 44 | Go to the project directory 45 | 46 | ```bash 47 | cd haru-api 48 | ``` 49 | 50 | Remove remote origin 51 | 52 | ```bash 53 | git remote remove origin 54 | ``` 55 | 56 | Install dependencies 57 | 58 | ```bash 59 | npm install 60 | ``` 61 | 62 | Add Environment Variables 63 | _add the following environment variables to .env file. (some env var include example values)_ 64 | 65 | 66 | Click to expand! 67 | 68 | - `NODE_ENV` 69 | - `PORT` 70 | - `POSTGRES_USER=testuser` 71 | - `POSTGRES_PASSWORD=test123` 72 | - `POSTGRES_DB=haru` 73 | - `JWT_SECRET` 74 | - `SMTP_HOST` 75 | - `SMTP_PORT` 76 | - `SMTP_USER` 77 | - `SMTP_PASS` 78 | - `FROM_NAME` 79 | - `FROM_MAIL` 80 | - `DATABASE_URL="postgresql://testuser:test123@postgres:5432/haru?schema=public"` 81 | 82 | 83 | Migrate and seed database 84 | 85 | ```bash 86 | npx prisma migrate dev --name init 87 | ``` 88 | 89 | ```bash 90 | npx prisma db seed 91 | ``` 92 | 93 | 94 | Can't reach database server Error ? 95 | 96 | - _Change_ **@postgres** _to_ **@localhost** _in_ `DATABASE_URL` _inside .env **for a while**_ 97 | 98 | ```bash 99 | DATABASE_URL="postgresql://testuser:test123@postgres:5432/test_db?schema=public" 100 | ``` 101 | 102 | ⬇️ 103 | 104 | ```bash 105 | DATABASE_URL="postgresql://testuser:test123@localhost:5432/test_db?schema=public" 106 | ``` 107 | 108 | 109 | 110 | Start the server 111 | 112 | ```bash 113 | npm run dev 114 | ``` 115 | 116 | Stop the server 117 | 118 | ```bash 119 | npm run dev:down 120 | ``` 121 | 122 | ## Running Tests 123 | 124 | To run tests, create a file called **.env.test** at the root of the project. 125 | Then add the following environment variables. 126 | `NODE_ENV=testing` 127 | `DATABASE_URL="postgresql://prisma:prisma@localhost:5437/tests"` 128 | 129 | Note! dotenv-cli must be installed golbally before running any test 130 | 131 | ```bash 132 | sudo npm install -g dotenv-cli 133 | ``` 134 | 135 | Run the test 136 | 137 | ```bash 138 | npm run test 139 | ``` 140 | 141 | 142 | Can't reach database server Error ? 143 | 144 | - Run the test again 145 | 146 | 147 | 148 | Stop the test 149 | 150 | ```bash 151 | npm run test:down 152 | ``` 153 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | // import morgan from "morgan"; 3 | import helmet from "helmet"; 4 | import rateLimit from "express-rate-limit"; 5 | import cors from "cors"; 6 | import ErrorResponse from "./utils/errorResponse"; 7 | import { resource404Error } from "./utils/errorObject"; 8 | import errorHandler from "./middlewares/errorHandler"; 9 | 10 | // import routes 11 | import categories from "./routers/categories"; 12 | import products from "./routers/products"; 13 | import customers from "./routers/customers"; 14 | import admins from "./routers/admins"; 15 | import auth from "./routers/auth"; 16 | import orders from "./routers/orders"; 17 | 18 | const app = express(); 19 | 20 | // Enable CORS 21 | app.use(cors()); 22 | 23 | // Set HTTP Hseaders 24 | app.use(helmet()); 25 | 26 | // Set Rate Limit 27 | const limiter = rateLimit({ 28 | windowMs: 7 * 60 * 1000, // 7 minutes 29 | max: 100, // limit each IP to 100 requests per windowMs 30 | }); 31 | app.use(limiter); 32 | 33 | // process.env.NODE_ENV === "development" && app.use(morgan("dev")); 34 | 35 | app.use(express.json()); 36 | app.use(express.urlencoded({ extended: true })); 37 | 38 | // Routes 39 | app.use("/api/v1/categories", categories); 40 | app.use("/api/v1/products", products); 41 | app.use("/api/v1/orders", orders); 42 | app.use("/api/v1/customers", customers); 43 | app.use("/api/v1/auth", auth); 44 | app.use("/api/v1/admins", admins); 45 | 46 | // 404 error if route not found 47 | app.all("*", (req, res, next) => 48 | next(new ErrorResponse(resource404Error("route"), 404)) 49 | ); 50 | 51 | app.use(errorHandler); 52 | 53 | export default app; 54 | -------------------------------------------------------------------------------- /controllers/admins.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import "jest-extended"; 3 | import app from "../app"; 4 | import prisma from "../prisma/client"; 5 | import { 6 | errorTypes, 7 | authRequiredError, 8 | incorrectCredentialsError, 9 | unauthorizedError, 10 | resource404Error, 11 | } from "../utils/errorObject"; 12 | 13 | const url = "/api/v1/admins"; 14 | 15 | type AdminType = { 16 | username: string; 17 | email: string; 18 | password: string; 19 | role?: "SUPERADMIN" | "ADMIN" | "MOERATOR"; 20 | }; 21 | 22 | const testAdmin: AdminType = { 23 | username: "testadmin", 24 | email: "testadmin15@gmail.com", 25 | password: "testadminpassword", 26 | }; 27 | 28 | let authToken = ""; 29 | let createdAdminToken = ""; 30 | 31 | describe("Admins", () => { 32 | describe("Login Admin", () => { 33 | it("POST /admins/login --> should login as admin", async () => { 34 | const response = await request(app) 35 | .post(`${url}/login`) 36 | .send({ email: "superadmin@gmail.com", password: "superadmin" }) 37 | .expect("Content-Type", /json/) 38 | .expect(200); 39 | 40 | expect(response.body.success).toBe(true); 41 | expect(response.body.token).toBeString(); 42 | authToken = response.body.token; 43 | }); 44 | 45 | it("POST /admins/login --> should throw error if required fields not include", async () => { 46 | const response = await request(app) 47 | .post(`${url}/login`) 48 | .expect("Content-Type", /json/) 49 | .expect(400); 50 | 51 | expect(response.body.success).toBe(false); 52 | expect(response.body.error).toEqual({ 53 | status: 400, 54 | type: "invalidArgument", 55 | message: "invalid one or more argument(s)", 56 | detail: [ 57 | { 58 | code: "missingEmail", 59 | message: "email field is missing", 60 | }, 61 | { 62 | code: "missingPassword", 63 | message: "password field is missing", 64 | }, 65 | ], 66 | }); 67 | }); 68 | 69 | it("POST /admins/login --> should throw error if email or password is incorrect", async () => { 70 | const response = await request(app) 71 | .post(`${url}/login`) 72 | .send({ email: "dummy@gmail.com", password: "wrongpassword" }) 73 | .expect("Content-Type", /json/) 74 | .expect(401); 75 | 76 | expect(response.body.success).toBe(false); 77 | expect(response.body.error).toEqual(incorrectCredentialsError); 78 | }); 79 | }); 80 | 81 | describe("Create Admin", () => { 82 | it("POST /admins --> should create an admin", async () => { 83 | const response = await request(app) 84 | .post(url) 85 | .set("Authorization", "Bearer " + authToken) 86 | .send(testAdmin) 87 | .expect("Content-Type", /json/) 88 | .expect(201); 89 | 90 | expect(response.body.success).toBe(true); 91 | expect(response.body.data).toEqual(expect.objectContaining(testAdmin)); 92 | }); 93 | 94 | it("POST /admins --> should throw error if not authorized", async () => { 95 | const loginResponse = await request(app) 96 | .post(`${url}/login`) 97 | .send({ email: "admin@gmail.com", password: "admin" }) 98 | .expect("Content-Type", /json/) 99 | .expect(200); 100 | const loginAdminToken = loginResponse.body.token; 101 | 102 | const response = await request(app) 103 | .post(url) 104 | .set("Authorization", "Bearer " + loginAdminToken) 105 | .send(testAdmin) 106 | .expect("Content-Type", /json/) 107 | .expect(403); 108 | 109 | expect(response.body.success).toBe(false); 110 | expect(response.body.error).toEqual(unauthorizedError); 111 | }); 112 | 113 | it("POST /admins --> should throw error if email already exists", async () => { 114 | const response = await request(app) 115 | .post(url) 116 | .set("Authorization", "Bearer " + authToken) 117 | .send({ ...testAdmin, email: "superadmin@gmail.com" }) 118 | .expect("Content-Type", /json/) 119 | .expect(400); 120 | 121 | expect(response.body.success).toBe(false); 122 | expect(response.body.error).toEqual({ 123 | status: 400, 124 | type: "alreadyExists", 125 | message: "email already exists", 126 | }); 127 | }); 128 | 129 | it("POST /admins --> throws error if required field is missing", async () => { 130 | const response = await request(app) 131 | .post(url) 132 | .set("Authorization", "Bearer " + authToken) 133 | .expect("Content-Type", /json/) 134 | .expect(400); 135 | 136 | expect(response.body.success).toBe(false); 137 | expect(response.body.error).toEqual({ 138 | status: 400, 139 | type: "invalidArgument", 140 | message: "invalid one or more argument(s)", 141 | detail: [ 142 | { 143 | code: "missingUsername", 144 | message: "username field is missing", 145 | }, 146 | { 147 | code: "missingEmail", 148 | message: "email field is missing", 149 | }, 150 | { 151 | code: "missingPassword", 152 | message: "password field is missing", 153 | }, 154 | ], 155 | }); 156 | }); 157 | 158 | it("POST /admins --> should validate email", async () => { 159 | const response = await request(app) 160 | .post(url) 161 | .set("Authorization", "Bearer " + authToken) 162 | .send({ ...testAdmin, email: "thisisnotavalidemailaddress" }) 163 | .expect("Content-Type", /json/) 164 | .expect(400); 165 | 166 | expect(response.body.success).toBe(false); 167 | expect(response.body.error).toEqual({ 168 | status: 400, 169 | type: errorTypes.invalidArgument, 170 | message: "email is not valid", 171 | }); 172 | }); 173 | 174 | it("POST /admins --> should throw error if role is not superadmin, admin, or mod", async () => { 175 | const response = await request(app) 176 | .post(url) 177 | .set("Authorization", "Bearer " + authToken) 178 | .send({ ...testAdmin, role: "DUMMY" }) 179 | .expect("Content-Type", /json/) 180 | .expect(400); 181 | 182 | expect(response.body.success).toBe(false); 183 | expect(response.body.error).toEqual({ 184 | status: 400, 185 | type: errorTypes.invalidArgument, 186 | message: "role type is not valid", 187 | detail: [ 188 | { 189 | code: "invalidRole", 190 | message: 191 | "role must be one of 'SUPERADMIN', 'ADMIN', and 'MODERATOR'", 192 | }, 193 | ], 194 | }); 195 | }); 196 | }); 197 | 198 | describe("Update Admin", () => { 199 | it("PUT /admins --> should update admin data (self)", async () => { 200 | // login first 201 | const loginRresponse = await request(app) 202 | .post(`${url}/login`) 203 | .send({ email: testAdmin.email, password: testAdmin.password }) 204 | .expect("Content-Type", /json/) 205 | .expect(200); 206 | 207 | const updateAdmin = { 208 | username: "new admin name", 209 | email: "newemail2@gmail.com", 210 | }; 211 | 212 | const response = await request(app) 213 | .put(url) 214 | .set("Authorization", "Bearer " + loginRresponse.body.token) 215 | .send(updateAdmin) 216 | .expect("Content-Type", /json/) 217 | .expect(200); 218 | 219 | expect(response.body.success).toBe(true); 220 | expect(response.body.data).toEqual({ 221 | ...updateAdmin, 222 | updatedAt: expect.any(String), 223 | }); 224 | 225 | // Update to previous testAdmin again 226 | const response2 = await request(app) 227 | .put(url) 228 | .set("Authorization", "Bearer " + loginRresponse.body.token) 229 | .send({ username: testAdmin.username, email: testAdmin.email }) 230 | .expect("Content-Type", /json/) 231 | .expect(200); 232 | 233 | expect(response2.body.success).toBe(true); 234 | }); 235 | 236 | it("PUT /admins/change-password --> should update password", async () => { 237 | // login first 238 | const loginRresponse = await request(app) 239 | .post(`${url}/login`) 240 | .send({ email: testAdmin.email, password: testAdmin.password }) 241 | .expect("Content-Type", /json/) 242 | .expect(200); 243 | 244 | const response = await request(app) 245 | .put(`${url}/change-password`) 246 | .set("Authorization", "Bearer " + loginRresponse.body.token) 247 | .send({ 248 | currentPassword: testAdmin.password, 249 | newPassword: "newpassword", 250 | }) 251 | .expect("Content-Type", /json/) 252 | .expect(200); 253 | 254 | expect(response.body.success).toBe(true); 255 | expect(response.body.message).toEqual("password has been updated"); 256 | }); 257 | 258 | it("PUT /admins/change-password --> should return error if current password is incorrect", async () => { 259 | // login first 260 | const loginRresponse = await request(app) 261 | .post(`${url}/login`) 262 | .send({ email: testAdmin.email, password: "newpassword" }) 263 | .expect("Content-Type", /json/) 264 | .expect(200); 265 | 266 | const response = await request(app) 267 | .put(`${url}/change-password`) 268 | .set("Authorization", "Bearer " + loginRresponse.body.token) 269 | .send({ 270 | currentPassword: "wrong password", 271 | newPassword: "newpassword", 272 | }) 273 | .expect("Content-Type", /json/) 274 | .expect(401); 275 | 276 | expect(response.body.success).toBe(false); 277 | expect(response.body.error).toEqual({ 278 | ...incorrectCredentialsError, 279 | message: "current password is incorrect", 280 | }); 281 | 282 | // delete admin after register and test 283 | // const deleteAdmin = await prisma.admin.delete({ 284 | // where: { email: testAdmin.email }, 285 | // }); 286 | // expect(deleteAdmin).toBeDefined(); 287 | }); 288 | 289 | it("PUT /admins/:id --> should update an admin (by superadmin)", async () => { 290 | const admin = await prisma.admin.findUnique({ 291 | where: { email: testAdmin.email }, 292 | }); 293 | 294 | const reqBody = { 295 | username: "updated name", 296 | email: "updatedemail2@gmail.com", 297 | password: "updatedpassword", 298 | role: "MODERATOR", 299 | active: false, 300 | }; 301 | 302 | const response = await request(app) 303 | .put(`${url}/${admin!.id}`) 304 | .set("Authorization", "Bearer " + authToken) 305 | .send(reqBody) 306 | .expect("Content-Type", /json/) 307 | .expect(200); 308 | 309 | expect(response.body.success).toBe(true); 310 | expect(response.body.data).toEqual({ 311 | ...reqBody, 312 | id: expect.any(Number), 313 | createdAt: expect.any(String), 314 | updatedAt: expect.any(String), 315 | }); 316 | 317 | // restore default for testAdmin 318 | await request(app) 319 | .put(`${url}/${admin!.id}`) 320 | .set("Authorization", "Bearer " + authToken) 321 | .send(testAdmin) 322 | .expect("Content-Type", /json/) 323 | .expect(200); 324 | }); 325 | 326 | it("PUT /admins/:id --> should throw 404 Error if not found", async () => { 327 | const response = await request(app) 328 | .put(`${url}/999`) 329 | .set("Authorization", "Bearer " + authToken) 330 | .expect("Content-Type", /json/) 331 | .expect(404); 332 | 333 | expect(response.body.success).toBe(false); 334 | expect(response.body.error).toEqual({ 335 | status: 404, 336 | type: "notFound", 337 | message: "record to update not found.", 338 | }); 339 | }); 340 | }); 341 | 342 | describe("Delete Admin", () => { 343 | it("DELETE /admins/:id --> should delete admin", async () => { 344 | const adminToDelete = await prisma.admin.findUnique({ 345 | where: { email: testAdmin.email }, 346 | }); 347 | 348 | await request(app) 349 | .delete(`${url}/${adminToDelete!.id}`) 350 | .set("Authorization", "Bearer " + authToken) 351 | .expect("Content-Type", /json/) 352 | .expect(203); 353 | }); 354 | 355 | it("DELETE /admins/:id --> should throw error if admin not found", async () => { 356 | const response = await request(app) 357 | .delete(`${url}/9999`) 358 | .set("Authorization", "Bearer " + authToken) 359 | .expect("Content-Type", /json/) 360 | .expect(404); 361 | 362 | expect(response.body.success).toBe(false); 363 | expect(response.body.error).toEqual({ 364 | status: 404, 365 | type: errorTypes.notFound, 366 | message: "record to delete does not exist.", 367 | }); 368 | }); 369 | }); 370 | 371 | describe("Get Admins", () => { 372 | it("GET /admins --> should return all admins", async () => { 373 | const response = await request(app) 374 | .get(url) 375 | .set("Authorization", "Bearer " + authToken) 376 | .expect("Content-Type", /json/) 377 | .expect(200); 378 | 379 | expect(response.body.count).toBeNumber; 380 | expect(response.body.success).toBe(true); 381 | expect(response.body.data).toEqual( 382 | expect.arrayContaining([ 383 | { 384 | id: expect.any(Number), 385 | username: expect.any(String), 386 | email: expect.any(String), 387 | role: expect.toBeOneOf(["SUPERADMIN", "ADMIN", "MODERATOR"]), 388 | active: expect.any(Boolean), 389 | createdAt: expect.any(String), 390 | updatedAt: expect.toBeOneOf([String, null]), 391 | }, 392 | ]) 393 | ); 394 | }); 395 | 396 | it("GET /admins/:id --> should return specific admin", async () => { 397 | const response = await request(app) 398 | .get(`${url}/2`) 399 | .set("Authorization", "Bearer " + authToken) 400 | .expect("Content-Type", /json/) 401 | .expect(200); 402 | 403 | expect(response.body.success).toBe(true); 404 | expect(response.body.data.id).toBe(2); 405 | }); 406 | 407 | it("GET /admins/:id --> should throw 404 Error if not found", async () => { 408 | const response = await request(app) 409 | .get(`${url}/999`) 410 | .set("Authorization", "Bearer " + authToken) 411 | .expect("Content-Type", /json/) 412 | .expect(404); 413 | 414 | expect(response.body.success).toBe(false); 415 | expect(response.body.error).toEqual(resource404Error("admin")); 416 | }); 417 | }); 418 | 419 | describe("Access Protected Route", () => { 420 | it("GET /admins/me --> should require authentication", async () => { 421 | const response = await request(app) 422 | .get(`${url}/me`) 423 | .expect("Content-Type", /json/) 424 | .expect(401); 425 | 426 | expect(response.body.success).toBe(false); 427 | expect(response.body.error).toEqual(authRequiredError); 428 | }); 429 | 430 | it("GET /admins/me --> should return logged in user", async () => { 431 | const response = await request(app) 432 | .get(`${url}/me`) 433 | .set("Authorization", "Bearer " + authToken) 434 | .expect("Content-Type", /json/) 435 | .expect(200); 436 | 437 | expect(response.body.success).toBe(true); 438 | expect(response.body.data).toEqual({ 439 | id: expect.any(Number), 440 | username: expect.any(String), 441 | email: expect.any(String), 442 | role: expect.toBeOneOf(["SUPERADMIN", "ADMIN", "MODERATOR"]), 443 | }); 444 | }); 445 | }); 446 | }); 447 | -------------------------------------------------------------------------------- /controllers/admins.ts: -------------------------------------------------------------------------------- 1 | import asyncHandler from "../middlewares/asyncHandler"; 2 | import prisma from "../prisma/client"; 3 | import { customers, categories, products, admins } from "../db/data"; 4 | import { 5 | invalidEmail, 6 | incorrectCredentialsError, 7 | resource404Error, 8 | roleError, 9 | unauthorizedError, 10 | } from "../utils/errorObject"; 11 | import ErrorResponse from "../utils/errorResponse"; 12 | import { ExtendedRequest } from "../utils/extendedRequest"; 13 | import { 14 | checkRequiredFields, 15 | checkRole, 16 | comparePassword, 17 | generateToken, 18 | hashPassword, 19 | validateEmail, 20 | } from "../utils/helperFunctions"; 21 | 22 | /** 23 | * Create admin 24 | * @route POST /api/v1/admins 25 | * @access Private (superadmin) 26 | */ 27 | export const createAdmin = asyncHandler(async (req, res, next) => { 28 | const username = req.body.username; 29 | const email = req.body.email; 30 | const password = req.body.password; 31 | const role = req.body.role; 32 | 33 | // Check required fields 34 | const requiredFields = { username, email, password }; 35 | const hasError = checkRequiredFields(requiredFields, next); 36 | if (hasError !== false) return hasError; 37 | 38 | // Validate Email 39 | const validEmail = validateEmail(email); 40 | if (!validEmail) return next(new ErrorResponse(invalidEmail, 400)); 41 | 42 | // Hash plain password 43 | const hashedPassword = await hashPassword(password); 44 | 45 | // Check role is either SUPERADMIN, ADMIN or MODERATOR 46 | if (role !== undefined) { 47 | if (!checkRole(role)) return next(new ErrorResponse(roleError, 400)); 48 | } 49 | 50 | const admin = await prisma.admin.create({ 51 | data: { 52 | email, 53 | password: hashedPassword, 54 | username, 55 | role, 56 | }, 57 | }); 58 | 59 | res.status(201).json({ 60 | success: true, 61 | data: { 62 | username, 63 | email, 64 | password, 65 | }, 66 | }); 67 | }); 68 | 69 | /** 70 | * Login admin 71 | * @route POST /api/v1/admins/login 72 | * @access PUBLIC 73 | */ 74 | export const loginAdmin = asyncHandler(async (req, res, next) => { 75 | const email: string | undefined = req.body.email; 76 | const password: string | undefined = req.body.password; 77 | 78 | // Throws error if required fields not specify 79 | const requiredFields = { email, password }; 80 | const hasError = checkRequiredFields(requiredFields, next); 81 | if (hasError !== false) return hasError; 82 | 83 | const admin = await prisma.admin.findUnique({ 84 | where: { email }, 85 | }); 86 | 87 | // Throws error if email is incorrect 88 | if (!admin) { 89 | return next(new ErrorResponse(incorrectCredentialsError, 401)); 90 | } 91 | 92 | // Check pwd with hashed pwd stored in db 93 | const result = await comparePassword(password as string, admin.password); 94 | 95 | // Throws error if password is incorrect 96 | if (!result) { 97 | return next(new ErrorResponse(incorrectCredentialsError, 401)); 98 | } 99 | 100 | // Generate a jwt 101 | const token = generateToken(admin.id, admin.email); 102 | 103 | res.status(200).json({ 104 | success: true, 105 | token, 106 | }); 107 | }); 108 | 109 | /** 110 | * Get current logged-in admin 111 | * @route GET /api/v1/admins/me 112 | * @access Private 113 | */ 114 | export const getMe = asyncHandler(async (req: ExtendedRequest, res, next) => { 115 | const user = await prisma.admin.findUnique({ 116 | where: { id: req!.admin!.id }, 117 | select: { 118 | id: true, 119 | username: true, 120 | email: true, 121 | role: true, 122 | }, 123 | }); 124 | 125 | res.status(200).json({ 126 | success: true, 127 | data: user, 128 | }); 129 | }); 130 | 131 | /** 132 | * Change current logged-in admin password 133 | * @route PUT /api/v1/admins/change-password 134 | * @access Private 135 | */ 136 | export const changePassword = asyncHandler( 137 | async (req: ExtendedRequest, res, next) => { 138 | const currentPassword = req.body.currentPassword; 139 | const newPassword = req.body.newPassword; 140 | 141 | const currentUserId = req!.admin!.id; 142 | 143 | // Check required fields 144 | const requiredFields = { currentPassword, newPassword }; 145 | const hasError = checkRequiredFields(requiredFields, next); 146 | if (hasError !== false) return hasError; 147 | 148 | // Check current password is correct 149 | const correctPassword = await comparePassword( 150 | currentPassword, 151 | req!.admin!.password 152 | ); 153 | 154 | // Throws error if current password is incorrect 155 | if (!correctPassword) 156 | return next( 157 | new ErrorResponse( 158 | { 159 | ...incorrectCredentialsError, 160 | message: "current password is incorrect", 161 | }, 162 | 401 163 | ) 164 | ); 165 | 166 | // Hash new password 167 | const hashedPassword = await hashPassword(newPassword); 168 | 169 | await prisma.admin.update({ 170 | where: { id: currentUserId }, 171 | data: { password: hashedPassword }, 172 | }); 173 | 174 | res.status(200).json({ 175 | success: true, 176 | message: "password has been updated", 177 | }); 178 | } 179 | ); 180 | 181 | /** 182 | * Update admin by current admin 183 | * @route PUT /api/v1/admins 184 | * @access Private 185 | */ 186 | export const updateAdminSelf = asyncHandler( 187 | async (req: ExtendedRequest, res, next) => { 188 | const username: string | undefined = req.body.username; 189 | let email: string | undefined = req.body.email; 190 | 191 | // Throws error if email is invalid 192 | if (email && !validateEmail(email)) { 193 | return next(new ErrorResponse(invalidEmail, 400)); 194 | } 195 | 196 | const updatedAdmin = await prisma.admin.update({ 197 | where: { id: req!.admin!.id }, 198 | data: { 199 | username, 200 | email, 201 | updatedAt: new Date().toISOString(), 202 | }, 203 | select: { 204 | username: true, 205 | email: true, 206 | updatedAt: true, 207 | }, 208 | }); 209 | 210 | res.status(200).json({ 211 | success: true, 212 | data: updatedAdmin, 213 | }); 214 | } 215 | ); 216 | 217 | /** 218 | * Get all admins 219 | * @route GET /api/v1/admins 220 | * @access Private (superadmin) 221 | */ 222 | export const getAdmins = asyncHandler(async (req, res, next) => { 223 | const admins = await prisma.admin.findMany({ 224 | select: { 225 | id: true, 226 | username: true, 227 | email: true, 228 | active: true, 229 | role: true, 230 | createdAt: true, 231 | updatedAt: true, 232 | }, 233 | }); 234 | res.status(200).json({ 235 | success: true, 236 | count: admins.length, 237 | data: admins, 238 | }); 239 | }); 240 | 241 | /** 242 | * Get specific admin 243 | * @route GET /api/v1/admins/:id 244 | * @access Private (superadmin) 245 | */ 246 | export const getAdmin = asyncHandler(async (req, res, next) => { 247 | const id = parseInt(req.params.id); 248 | 249 | const admin = await prisma.admin.findUnique({ 250 | where: { id }, 251 | select: { 252 | id: true, 253 | username: true, 254 | email: true, 255 | active: true, 256 | role: true, 257 | createdAt: true, 258 | updatedAt: true, 259 | }, 260 | }); 261 | 262 | // Throws 404 error if admin not found 263 | if (!admin) return next(new ErrorResponse(resource404Error("admin"), 404)); 264 | 265 | res.status(200).json({ 266 | success: true, 267 | data: admin, 268 | }); 269 | }); 270 | 271 | /** 272 | * Update specific admin 273 | * @route PUT /api/v1/admins/:id 274 | * @access Private (superadmin) 275 | */ 276 | export const updateAdmin = asyncHandler(async (req, res, next) => { 277 | const id = parseInt(req.params.id); 278 | 279 | const username = req.body.username; 280 | const email = req.body.email; 281 | const password = req.body.password; 282 | const role = req.body.role; 283 | const active = req.body.active; 284 | let hashedPassword: string | undefined; 285 | 286 | // Check role if it is valid 287 | if (role !== undefined) { 288 | if (!checkRole(role)) return next(new ErrorResponse(roleError, 400)); 289 | } 290 | 291 | // Hash plain text password 292 | if (password) { 293 | hashedPassword = await hashPassword(password); 294 | } 295 | 296 | const admin = await prisma.admin.update({ 297 | where: { id }, 298 | data: { 299 | username, 300 | email, 301 | password: hashedPassword, 302 | role, 303 | active, 304 | updatedAt: new Date().toISOString(), 305 | }, 306 | }); 307 | 308 | res.status(200).json({ 309 | success: true, 310 | data: { ...admin, password }, 311 | }); 312 | }); 313 | 314 | /** 315 | * Delete user by id 316 | * @route DELETE /api/v1/admins/:id 317 | * @access Private (superadmin) 318 | */ 319 | export const deleteAdmin = asyncHandler(async (req, res, next) => { 320 | const id = parseInt(req.params.id); 321 | 322 | await prisma.admin.delete({ 323 | where: { id }, 324 | }); 325 | 326 | res.status(203).json({ 327 | success: true, 328 | }); 329 | }); 330 | 331 | /** 332 | * Seed Data 333 | * @route POST /api/v1/admins/seed 334 | * @access Private (superadmin) 335 | */ 336 | export const seedData = asyncHandler(async (req, res, next) => { 337 | const password = req.body.password; 338 | 339 | if (password !== process.env.SEEDING_PASSWORD) { 340 | return next(new ErrorResponse(unauthorizedError, 403)); 341 | } 342 | 343 | for (let customer of customers) { 344 | await prisma.customer.create({ 345 | data: customer, 346 | }); 347 | } 348 | 349 | for (let category of categories) { 350 | await prisma.category.create({ 351 | data: category, 352 | }); 353 | } 354 | 355 | for (let product of products) { 356 | await prisma.product.create({ 357 | data: product, 358 | }); 359 | } 360 | 361 | for (let admin of admins) { 362 | await prisma.admin.create({ 363 | data: admin, 364 | }); 365 | } 366 | 367 | res.status(201).json({ 368 | success: true, 369 | message: "Database seeding complete successfully", 370 | }); 371 | }); 372 | -------------------------------------------------------------------------------- /controllers/auth.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import app from "../app"; 3 | import "jest-extended"; 4 | import prisma from "../prisma/client"; 5 | import { 6 | errorTypes, 7 | authRequiredError, 8 | incorrectCredentialsError, 9 | } from "../utils/errorObject"; 10 | 11 | const url = "/api/v1/auth"; 12 | 13 | const newUser = { 14 | email: "newuser7j@gmail.com", 15 | fullname: "newuser", 16 | password: "newuserpassword", 17 | shippingAddress: "yangon", 18 | phone: "09283928", 19 | }; 20 | 21 | const updateUser = { 22 | fullname: "new username", 23 | email: "updatedemail3@gmail.com", 24 | shippingAddress: "updated shipping addr", 25 | phone: "099384938", 26 | }; 27 | 28 | let authToken: string; 29 | 30 | describe("Auth Controller", () => { 31 | describe("Regsiter Customer", () => { 32 | it("POST /auth/register --> should register new customer", async () => { 33 | const response = await request(app) 34 | .post(`${url}/register`) 35 | .send(newUser) 36 | .expect("Content-Type", /json/) 37 | .expect(201); 38 | 39 | expect(response.body.success).toBe(true); 40 | expect(response.body.token).toBeString(); 41 | }); 42 | 43 | it("POST /auth/register --> should throw error if required fields not include", async () => { 44 | const response = await request(app) 45 | .post(`${url}/register`) 46 | .expect("Content-Type", /json/) 47 | .expect(400); 48 | 49 | expect(response.body.success).toBe(false); 50 | expect(response.body.error).toEqual({ 51 | status: 400, 52 | type: "invalidArgument", 53 | message: "invalid one or more argument(s)", 54 | detail: [ 55 | { 56 | code: "missingEmail", 57 | message: "email field is missing", 58 | }, 59 | { 60 | code: "missingFullname", 61 | message: "fullname field is missing", 62 | }, 63 | { 64 | code: "missingPassword", 65 | message: "password field is missing", 66 | }, 67 | ], 68 | }); 69 | }); 70 | 71 | it("POST /auth/register --> should throw error if email already exists", async () => { 72 | const response = await request(app) 73 | .post(`${url}/register`) 74 | .send({ ...newUser, email: "dgohn0@gravatar.com" }) 75 | .expect("Content-Type", /json/) 76 | .expect(400); 77 | 78 | expect(response.body.success).toBe(false); 79 | expect(response.body.error).toEqual({ 80 | status: 400, 81 | type: "alreadyExists", 82 | message: "email already exists", 83 | }); 84 | }); 85 | 86 | it("POST /auth/register --> should validate email", async () => { 87 | const response = await request(app) 88 | .post(`${url}/register`) 89 | .send({ ...newUser, email: "thisisnotavalidemailaddress" }) 90 | .expect("Content-Type", /json/) 91 | .expect(400); 92 | 93 | expect(response.body.success).toBe(false); 94 | expect(response.body.error).toEqual({ 95 | status: 400, 96 | type: errorTypes.invalidArgument, 97 | message: "email is not valid", 98 | }); 99 | }); 100 | }); 101 | 102 | describe("Update Customer", () => { 103 | let loginToken = ""; 104 | it("PUT /auth/update-details --> should update customer data (self)", async () => { 105 | // login 106 | const loginRresponse = await request(app) 107 | .post(`${url}/login`) 108 | .send({ email: newUser.email, password: newUser.password }) 109 | .expect("Content-Type", /json/) 110 | .expect(200); 111 | 112 | loginToken = loginRresponse.body.token; 113 | 114 | const response = await request(app) 115 | .put(`${url}/update-details`) 116 | .set("Authorization", "Bearer " + loginToken) 117 | .send(updateUser) 118 | .expect("Content-Type", /json/) 119 | .expect(200); 120 | 121 | expect(response.body.success).toBe(true); 122 | expect(response.body.data).toEqual({ 123 | ...updateUser, 124 | updatedAt: expect.any(String), 125 | }); 126 | }); 127 | 128 | it("PUT /auth/change-password --> should return error if current password is incorrect", async () => { 129 | const response = await request(app) 130 | .put(`${url}/change-password`) 131 | .set("Authorization", "Bearer " + loginToken) 132 | .send({ 133 | currentPassword: "wrong password", 134 | newPassword: "newpassword", 135 | }) 136 | .expect("Content-Type", /json/) 137 | .expect(401); 138 | 139 | expect(response.body.success).toBe(false); 140 | expect(response.body.error).toEqual({ 141 | ...incorrectCredentialsError, 142 | message: "current password is incorrect", 143 | }); 144 | }); 145 | 146 | it("PUT /auth/change-password --> should update password", async () => { 147 | const response = await request(app) 148 | .put(`${url}/change-password`) 149 | .set("Authorization", "Bearer " + loginToken) 150 | .send({ 151 | currentPassword: newUser.password, 152 | newPassword: "newpassword", 153 | }) 154 | .expect("Content-Type", /json/) 155 | .expect(200); 156 | 157 | expect(response.body.success).toBe(true); 158 | expect(response.body.message).toEqual("password has been updated"); 159 | 160 | // delete user after register and test 161 | const deleteUser = await prisma.customer.delete({ 162 | where: { email: updateUser.email }, 163 | }); 164 | expect(deleteUser).toBeDefined(); 165 | }); 166 | }); 167 | 168 | describe("Login Customer", () => { 169 | it("POST /auth/login --> should login customer", async () => { 170 | const response = await request(app) 171 | .post(`${url}/login`) 172 | .send({ email: "demo@gmail.com", password: "demopassword" }) 173 | .expect("Content-Type", /json/) 174 | .expect(200); 175 | 176 | expect(response.body.success).toBe(true); 177 | expect(response.body.token).toBeString(); 178 | authToken = response.body.token; 179 | }); 180 | 181 | it("POST /auth/login --> should throw error if required fields not include", async () => { 182 | const response = await request(app) 183 | .post(`${url}/login`) 184 | .expect("Content-Type", /json/) 185 | .expect(400); 186 | 187 | expect(response.body.success).toBe(false); 188 | expect(response.body.error).toEqual({ 189 | status: 400, 190 | type: "invalidArgument", 191 | message: "invalid one or more argument(s)", 192 | detail: [ 193 | { 194 | code: "missingEmail", 195 | message: "email field is missing", 196 | }, 197 | { 198 | code: "missingPassword", 199 | message: "password field is missing", 200 | }, 201 | ], 202 | }); 203 | }); 204 | 205 | it("POST /auth/login --> should throw error if email or password is incorrect", async () => { 206 | const response = await request(app) 207 | .post(`${url}/login`) 208 | .send({ email: "dummy@gmail.com", password: "wrongpassword" }) 209 | .expect("Content-Type", /json/) 210 | .expect(401); 211 | 212 | expect(response.body.success).toBe(false); 213 | expect(response.body.error).toEqual(incorrectCredentialsError); 214 | }); 215 | }); 216 | 217 | describe("Access Protected Route", () => { 218 | it("GET /auth/me --> should require authentication", async () => { 219 | const response = await request(app) 220 | .get(`${url}/me`) 221 | .expect("Content-Type", /json/) 222 | .expect(401); 223 | 224 | expect(response.body.success).toBe(false); 225 | expect(response.body.error).toEqual(authRequiredError); 226 | }); 227 | 228 | it("GET /auth/me --> should return logged in user", async () => { 229 | const response = await request(app) 230 | .get(`${url}/me`) 231 | .set("Authorization", "Bearer " + authToken) 232 | .expect("Content-Type", /json/) 233 | .expect(200); 234 | 235 | expect(response.body.success).toBe(true); 236 | expect(response.body.data).toEqual({ 237 | id: expect.any(Number), 238 | fullname: expect.any(String), 239 | email: expect.any(String), 240 | shippingAddress: expect.any(String), 241 | phone: expect.toBeOneOf([String, null]), 242 | }); 243 | }); 244 | }); 245 | 246 | describe("Forgot and Reset Password", () => { 247 | it("POST /auth/forgot-password --> should throws error if email not include", async () => { 248 | const response = await request(app) 249 | .post(`${url}/forgot-password`) 250 | .expect("Content-Type", /json/) 251 | .expect(400); 252 | 253 | expect(response.body.success).toBe(false); 254 | expect(response.body.error).toEqual({ 255 | status: 400, 256 | type: errorTypes.invalidArgument, 257 | message: "invalid one or more argument(s)", 258 | detail: [ 259 | { 260 | code: "missingEmail", 261 | message: "email field is missing", 262 | }, 263 | ], 264 | }); 265 | }); 266 | 267 | it("POST /auth/forgot-password --> should throws 404 error if email not found", async () => { 268 | const response = await request(app) 269 | .post(`${url}/forgot-password`) 270 | .send({ email: "invalidemail@gmail.com" }) 271 | .expect("Content-Type", /json/) 272 | .expect(404); 273 | 274 | expect(response.body.success).toBe(false); 275 | expect(response.body.error).toEqual({ 276 | status: 404, 277 | type: "notFound", 278 | message: "record to update not found.", 279 | }); 280 | }); 281 | 282 | // it("POST /auth/forgot-password --> should send email", async () => { 283 | // const response = await request(app) 284 | // .post(`${url}/forgot-password`) 285 | // .send({ email: "dgohn0@gravatar.com" }) 286 | // .expect("Content-Type", /json/) 287 | // .expect(200); 288 | 289 | // expect(response.body.success).toBe(true); 290 | // expect(response.body.message).toEqual("Email has been sent..."); 291 | // }); 292 | 293 | /*=========== Reset Password ===========*/ 294 | 295 | it("POST /auth/reset-password/resetToken --> should throws error if password not include", async () => { 296 | const response = await request(app) 297 | .put(`${url}/reset-password/resetToken`) 298 | .expect("Content-Type", /json/) 299 | .expect(400); 300 | 301 | expect(response.body.success).toBe(false); 302 | expect(response.body.error).toEqual({ 303 | status: 400, 304 | type: errorTypes.invalidArgument, 305 | message: "invalid one or more argument(s)", 306 | detail: [ 307 | { 308 | code: "missingPassword", 309 | message: "password field is missing", 310 | }, 311 | ], 312 | }); 313 | }); 314 | }); 315 | }); 316 | -------------------------------------------------------------------------------- /controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import asyncHandler from "../middlewares/asyncHandler"; 3 | import prisma from "../prisma/client"; 4 | import { 5 | checkRequiredFields, 6 | comparePassword, 7 | generateResetPwdToken, 8 | generateToken, 9 | hashPassword, 10 | validateEmail, 11 | } from "../utils/helperFunctions"; 12 | import ErrorResponse from "../utils/errorResponse"; 13 | import errorObj, { 14 | defaultError, 15 | errorTypes, 16 | expireTokenError, 17 | incorrectCredentialsError, 18 | invalidEmail, 19 | invalidTokenError, 20 | } from "../utils/errorObject"; 21 | import { ExtendedRequest } from "../utils/extendedRequest"; 22 | import sendMail from "../utils/sendEmail"; 23 | 24 | /** 25 | * Register new customer 26 | * @route POST /api/v1/auth/register 27 | * @access Public 28 | */ 29 | export const registerCustomer = asyncHandler(async (req, res, next) => { 30 | const email: string = req.body.email; 31 | const fullname: string = req.body.fullname; 32 | let password: string = req.body.password; 33 | const shippingAddress: string = req.body.shippingAddress; 34 | const phone: string = req.body.phone; // null 35 | 36 | // Check required fields 37 | const requiredFields = { email, fullname, password }; 38 | const hasError = checkRequiredFields(requiredFields, next); 39 | if (hasError !== false) return hasError; 40 | 41 | // Validate email 42 | if (!validateEmail(email)) { 43 | const emailError = errorObj( 44 | 400, 45 | errorTypes.invalidArgument, 46 | "email is not valid" 47 | ); 48 | return next(new ErrorResponse(emailError, 400)); 49 | } 50 | 51 | // Hash password 52 | password = await hashPassword(password); 53 | 54 | const customer = await prisma.customer.create({ 55 | data: { 56 | email, 57 | fullname, 58 | password, 59 | shippingAddress, 60 | phone, 61 | }, 62 | }); 63 | 64 | const token = generateToken(customer.id, customer.email); 65 | 66 | res.status(201).json({ 67 | success: true, 68 | id: customer.id, 69 | token: token, 70 | }); 71 | }); 72 | 73 | /** 74 | * Login customer 75 | * @route POST /api/v1/auth/login 76 | * @access Public 77 | */ 78 | export const loginCustomer = asyncHandler(async (req, res, next) => { 79 | const email = req.body.email; 80 | const password = req.body.password; 81 | 82 | // Check required fields 83 | const requiredFields = { email, password }; 84 | const hasError = checkRequiredFields(requiredFields, next); 85 | if (hasError !== false) return hasError; 86 | 87 | const customer = await prisma.customer.findUnique({ 88 | where: { email }, 89 | }); 90 | 91 | // Throws error if customer does not exist 92 | if (!customer) { 93 | return next(new ErrorResponse(incorrectCredentialsError, 401)); 94 | } 95 | 96 | // Check pwd with hashed pwd stored in db 97 | const result = await comparePassword(password, customer.password); 98 | 99 | // Throws error if password is incorrect 100 | if (!result) { 101 | return next(new ErrorResponse(incorrectCredentialsError, 401)); 102 | } 103 | 104 | const token = generateToken(customer.id, customer.email); 105 | 106 | res.status(200).json({ 107 | success: true, 108 | token: token, 109 | data: { 110 | id: customer.id, 111 | email: customer.email, 112 | fullname: customer.fullname, 113 | shippingAddress: customer.shippingAddress, 114 | phone: customer.phone, 115 | }, 116 | }); 117 | }); 118 | 119 | /** 120 | * Get current logged-in user 121 | * @route GET /api/v1/auth/me 122 | * @access Private 123 | */ 124 | export const getMe = asyncHandler(async (req: ExtendedRequest, res, next) => { 125 | const user = await prisma.customer.findUnique({ 126 | where: { id: req!.user!.id }, 127 | select: { 128 | id: true, 129 | fullname: true, 130 | email: true, 131 | shippingAddress: true, 132 | phone: true, 133 | }, 134 | }); 135 | 136 | res.status(200).json({ 137 | success: true, 138 | data: user, 139 | }); 140 | }); 141 | 142 | /** 143 | * Update Customer Details (self) 144 | * @route PUT /api/v1/auth/update-details 145 | * @access Private 146 | */ 147 | export const updateCustomerSelf = asyncHandler( 148 | async (req: ExtendedRequest, res, next) => { 149 | const fullname: string | undefined = req.body.fullname; 150 | const shippingAddress: string | undefined = req.body.shippingAddress; 151 | const phone: string | undefined = req.body.phone; 152 | const email: string | undefined = req.body.email; 153 | 154 | // Throws error if email is invalid 155 | if (email && !validateEmail(email)) { 156 | return next(new ErrorResponse(invalidEmail, 400)); 157 | } 158 | 159 | const updatedCustomer = await prisma.customer.update({ 160 | where: { id: req!.user!.id }, 161 | data: { 162 | fullname, 163 | email, 164 | shippingAddress, 165 | phone, 166 | updatedAt: new Date().toISOString(), 167 | }, 168 | select: { 169 | fullname: true, 170 | email: true, 171 | shippingAddress: true, 172 | phone: true, 173 | updatedAt: true, 174 | }, 175 | }); 176 | 177 | res.status(200).json({ 178 | success: true, 179 | data: updatedCustomer, 180 | }); 181 | } 182 | ); 183 | 184 | /** 185 | * Update Customer Password (self) 186 | * @route PUT /api/v1/auth/change-password 187 | * @access Private 188 | */ 189 | export const changePassword = asyncHandler( 190 | async (req: ExtendedRequest, res, next) => { 191 | const currentPassword = req.body.currentPassword; 192 | const newPassword = req.body.newPassword; 193 | 194 | // Check required fields 195 | const requiredFields = { currentPassword, newPassword }; 196 | const hasError = checkRequiredFields(requiredFields, next); 197 | if (hasError !== false) return hasError; 198 | 199 | // Check current password is correct 200 | const correctPassword = await comparePassword( 201 | currentPassword, 202 | req!.user!.password 203 | ); 204 | 205 | // Throws error if current password is incorrect 206 | if (!correctPassword) 207 | return next( 208 | new ErrorResponse( 209 | { 210 | ...incorrectCredentialsError, 211 | message: "current password is incorrect", 212 | }, 213 | 401 214 | ) 215 | ); 216 | 217 | // Hash new password 218 | const hashedPassword = await hashPassword(newPassword); 219 | 220 | await prisma.customer.update({ 221 | where: { id: req!.user!.id }, 222 | data: { password: hashedPassword }, 223 | }); 224 | 225 | res.status(200).json({ 226 | success: true, 227 | message: "password has been updated", 228 | }); 229 | } 230 | ); 231 | 232 | /** 233 | * Forgot Password 234 | * @route POST /api/v1/auth/forgot-password 235 | * @access Public 236 | */ 237 | export const forgotPassword = asyncHandler(async (req, res, next) => { 238 | const email: string | undefined = req.body.email; 239 | 240 | // Check if email include 241 | const hasError = checkRequiredFields({ email }, next); 242 | if (hasError !== false) return hasError; 243 | 244 | const [resetToken, resetPwdToken, resetPwdExpire] = generateResetPwdToken(); 245 | 246 | // Save pwdToken and pwdExpire in the db 247 | const customer = await prisma.customer.update({ 248 | where: { email }, 249 | data: { 250 | resetPwdToken: resetPwdToken as string, 251 | resetPwdExpire: resetPwdExpire as number, 252 | }, 253 | }); 254 | 255 | // Create reset URL 256 | const resetURL = `${req.protocol}://${req.get( 257 | "host" 258 | )}/api/v1/auth/reset-password/${resetToken}`; 259 | 260 | // Reset email message 261 | const message = `You are receiving this email because 262 | you (or someone else) has requested the reset of a password. 263 | Please make a PUT request to: \n\n ${resetURL}`; 264 | 265 | try { 266 | await sendMail({ 267 | email: customer.email, 268 | subject: "Password reset token (valid for 10min)", 269 | message, 270 | }); 271 | res.status(200).json({ 272 | success: true, 273 | message: "Email has been sent...", 274 | }); 275 | } catch (err) { 276 | // Log error 277 | console.error(err); 278 | 279 | // Save user 280 | await prisma.customer.update({ 281 | where: { id: customer.id }, 282 | data: { 283 | resetPwdToken: null, 284 | resetPwdExpire: null, 285 | }, 286 | }); 287 | 288 | return next(new ErrorResponse(defaultError, 500)); 289 | } 290 | }); 291 | 292 | /** 293 | * Reset Password 294 | * @route PUT /api/v1/auth/reset-password/:resetToken 295 | * @access Public 296 | */ 297 | export const resetPassword = asyncHandler(async (req, res, next) => { 298 | const resetToken = req.params.resetToken; 299 | const password = req.body.password; 300 | 301 | // Throws error if password not include 302 | const hasError = checkRequiredFields({ password }, next); 303 | if (hasError !== false) return hasError; 304 | 305 | const resetPwdToken = crypto 306 | .createHash("sha256") 307 | .update(resetToken) 308 | .digest("hex"); 309 | 310 | const customer = await prisma.customer.findUnique({ 311 | where: { resetPwdToken }, 312 | }); 313 | 314 | // Throws error if token not found 315 | if (!customer) return next(new ErrorResponse(invalidTokenError, 400)); 316 | 317 | // Throws error if token is expired 318 | if ((customer.resetPwdExpire as bigint) < Date.now()) { 319 | return next(new ErrorResponse(expireTokenError, 400)); 320 | } 321 | 322 | const hashedPassword = await hashPassword(password); 323 | 324 | // Update password and token data 325 | await prisma.customer.update({ 326 | where: { resetPwdToken }, 327 | data: { 328 | password: hashedPassword, 329 | updatedAt: new Date().toISOString(), 330 | resetPwdToken: null, 331 | resetPwdExpire: null, 332 | }, 333 | }); 334 | 335 | res.status(200).json({ 336 | success: true, 337 | message: "password has been reset", 338 | }); 339 | }); 340 | -------------------------------------------------------------------------------- /controllers/categories.test.ts: -------------------------------------------------------------------------------- 1 | import app from "../app"; 2 | import express from "express"; 3 | import request from "supertest"; 4 | import "jest-sorted"; 5 | import { errorTypes, resource404Error } from "../utils/errorObject"; 6 | 7 | const url = "/api/v1/categories"; 8 | 9 | const testCategory = { 10 | id: 778, 11 | name: "sneakers", 12 | description: "sapien non mi integer ac neque duis bibendum morbi non", 13 | thumbnailImage: "http://dummyimage.com/720x400.png/cc0000/ffffff", 14 | }; 15 | 16 | let authToken = ""; 17 | 18 | const adminLogin = async () => { 19 | const response = await request(app) 20 | .post(`/api/v1/admins/login`) 21 | .send({ email: "superadmin@gmail.com", password: "superadmin" }); 22 | authToken = response.body.token; 23 | }; 24 | 25 | beforeAll(() => adminLogin()); 26 | 27 | describe("Categories Controller", () => { 28 | describe("Get Categories", () => { 29 | it("GET /categories --> return categories", async () => { 30 | const response = await request(app) 31 | .get(url) 32 | .expect("Content-Type", /json/) 33 | .expect(200); 34 | 35 | expect(response.body.data).toBeDefined; 36 | expect(response.body).toEqual({ 37 | success: true, 38 | count: expect.any(Number), 39 | data: expect.arrayContaining([ 40 | expect.objectContaining({ 41 | id: expect.any(Number), 42 | name: expect.any(String), 43 | description: expect.any(String), 44 | thumbnailImage: expect.any(String), 45 | }), 46 | ]), 47 | }); 48 | }); 49 | 50 | it("GET /categories --> select name, description", async () => { 51 | const response = await request(app) 52 | .get(url) 53 | .query({ select: "name,description" }) 54 | .expect("Content-Type", /json/) 55 | .expect(200); 56 | 57 | expect(response.body).toEqual({ 58 | success: true, 59 | count: expect.any(Number), 60 | data: expect.arrayContaining([ 61 | { 62 | name: expect.any(String), 63 | description: expect.any(String), 64 | }, 65 | ]), 66 | }); 67 | }); 68 | 69 | it("GET /categories --> order_by name.desc", async () => { 70 | const response = await request(app) 71 | .get(url) 72 | .query({ order_by: "name.desc" }) 73 | .expect("Content-Type", /json/) 74 | .expect(200); 75 | 76 | expect(response.body.data).toBeSortedBy("name", { descending: true }); 77 | }); 78 | 79 | it("GET /categories/:id --> return specific category", async () => { 80 | const response = await request(app) 81 | .get(`${url}/3`) 82 | .expect("Content-Type", /json/) 83 | .expect(200); 84 | 85 | expect(response.body).toEqual({ 86 | success: true, 87 | data: expect.objectContaining({ 88 | id: 3, 89 | }), 90 | }); 91 | }); 92 | 93 | it("GET /categories/:id --> select name", async () => { 94 | const response = await request(app) 95 | .get(`${url}/3`) 96 | .query({ select: "name" }) 97 | .expect("Content-Type", /json/) 98 | .expect(200); 99 | 100 | expect(response.body).toEqual({ 101 | success: true, 102 | data: { 103 | name: expect.any(String), 104 | }, 105 | }); 106 | }); 107 | 108 | it("GET /categories/:id --> 404 if not found", async () => { 109 | const response = await request(app) 110 | .get(`${url}/99`) 111 | .expect("Content-Type", /json/) 112 | .expect(404); 113 | 114 | expect(response.body).toEqual({ 115 | success: false, 116 | error: resource404Error("category"), 117 | }); 118 | }); 119 | }); 120 | 121 | describe("Create Category", () => { 122 | it("POST /categories --> create a new category", async () => { 123 | const response = await request(app) 124 | .post(url) 125 | .set("Authorization", "Bearer " + authToken) 126 | .send(testCategory) 127 | .expect("Content-Type", /json/) 128 | .expect(201); 129 | 130 | const uriRegEx = /^([^:]*):([^:]*):(.*)\/categories\/\d*$/; 131 | 132 | expect(response.body.location).toMatch(uriRegEx); 133 | expect(response.body).toEqual( 134 | expect.objectContaining({ 135 | success: true, 136 | data: { 137 | ...testCategory, 138 | id: expect.any(Number), 139 | createdAt: expect.any(String), 140 | updatedAt: null, 141 | }, 142 | }) 143 | ); 144 | }); 145 | 146 | it("POST /categories --> return error if name already exists", async () => { 147 | const testCategory = { 148 | name: "men", 149 | description: "sapien non mi integer", 150 | }; 151 | const response = await request(app) 152 | .post(url) 153 | .send(testCategory) 154 | .set("Authorization", "Bearer " + authToken) 155 | .set("Accept", "application/json") 156 | .expect("Content-Type", /json/) 157 | .expect(400); 158 | 159 | expect(response.body).toEqual({ 160 | success: false, 161 | error: { 162 | status: 400, 163 | type: errorTypes.alreadyExists, 164 | message: "name already exists", 165 | }, 166 | }); 167 | }); 168 | 169 | it("POST /categories --> return error if name is not sent in body", async () => { 170 | const response = await request(app) 171 | .post(url) 172 | .set("Authorization", "Bearer " + authToken) 173 | .expect("Content-Type", /json/) 174 | .expect(400); 175 | 176 | expect(response.body).toEqual({ 177 | success: false, 178 | error: { 179 | status: 400, 180 | type: errorTypes.invalidArgument, 181 | message: "invalid one or more argument(s)", 182 | detail: [ 183 | { 184 | code: "missingName", 185 | message: "name field is missing", 186 | }, 187 | ], 188 | }, 189 | }); 190 | }); 191 | 192 | // Validation Body Fields 193 | 194 | // Auth Access 195 | }); 196 | 197 | describe("Update Category", () => { 198 | const reqBody = { 199 | name: "shoes", 200 | description: "Shoes and Sneakers", 201 | thumbnailImage: "somedummyimage.png", 202 | }; 203 | 204 | it("PUT /categories/:id --> should update category", async () => { 205 | const response = await request(app) 206 | .put(`${url}/${testCategory.id}`) 207 | .set("Authorization", "Bearer " + authToken) 208 | .send(reqBody) 209 | .expect("Content-Type", /json/) 210 | .expect(200); 211 | 212 | expect(response.body.success).toBe(true); 213 | expect(response.body.data).toEqual({ 214 | ...reqBody, 215 | id: expect.any(Number), 216 | createdAt: expect.any(String), 217 | updatedAt: expect.any(String), 218 | }); 219 | }); 220 | 221 | it("PUT /categories/:id --> 404 if category not found", async () => { 222 | const response = await request(app) 223 | .put(`${url}/9999`) 224 | .set("Authorization", "Bearer " + authToken) 225 | .send(reqBody) 226 | .expect("Content-Type", /json/) 227 | .expect(404); 228 | 229 | expect(response.body.success).toBe(false); 230 | expect(response.body.error).toEqual({ 231 | status: 404, 232 | type: errorTypes.notFound, 233 | message: "record to update not found.", 234 | }); 235 | }); 236 | }); 237 | 238 | describe("Delete Category", () => { 239 | it("DELETE /categories/:id --> delete a specific category", async () => { 240 | const response = await request(app) 241 | .delete(`${url}/${testCategory.id}`) 242 | .set("Authorization", "Bearer " + authToken) 243 | .expect(204); 244 | }); 245 | 246 | it("DELETE /categories/:id --> return 404 error if category not found", async () => { 247 | const response = await request(app) 248 | .delete(`${url}/${testCategory.id}`) 249 | .set("Authorization", "Bearer " + authToken) 250 | .expect("Content-Type", /json/) 251 | .expect(404); 252 | 253 | expect(response.body).toEqual({ 254 | success: false, 255 | error: { 256 | status: 404, 257 | type: errorTypes.notFound, 258 | message: "record to delete does not exist.", 259 | }, 260 | }); 261 | }); 262 | 263 | // it("DELETE /categories/:id --> return auth error if not admin", async() => {}); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /controllers/categories.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../prisma/client"; 2 | import asyncHandler from "../middlewares/asyncHandler"; 3 | import ErrorResponse from "../utils/errorResponse"; 4 | import { errorTypes, resource404Error } from "../utils/errorObject"; 5 | import { 6 | checkRequiredFields, 7 | orderedQuery, 8 | selectedQuery, 9 | } from "../utils/helperFunctions"; 10 | import { Prisma } from ".prisma/client"; 11 | 12 | /** 13 | * Get all categories 14 | * @route GET /api/v1/categories 15 | * @access Public 16 | */ 17 | export const getCategories = asyncHandler(async (req, res, next) => { 18 | // Type Declaration 19 | type OrderType = { [key: string]: string }; 20 | 21 | // Request Queries 22 | const querySelect = req.query.select; 23 | const queryOrder = req.query.order_by; 24 | 25 | // Filter and Sorting initial values 26 | let select: Prisma.CategorySelect | undefined = undefined; 27 | let orderBy: OrderType[] = []; 28 | 29 | // If select is sent along with request 30 | if (querySelect) { 31 | select = selectedQuery(querySelect as string); 32 | } 33 | 34 | // If order_by is sent along with request 35 | if (queryOrder) { 36 | orderBy = orderedQuery(queryOrder as string); 37 | } 38 | 39 | // Find categories with Prisma Client 40 | const categories = await prisma.category.findMany({ 41 | select, 42 | orderBy, 43 | }); 44 | 45 | res 46 | .status(200) 47 | .json({ success: true, count: categories.length, data: categories }); 48 | }); 49 | 50 | /** 51 | * Get specific category 52 | * @route GET /api/v1/categories/:id 53 | * @access Public 54 | */ 55 | export const getCategory = asyncHandler(async (req, res, next) => { 56 | const id = parseInt(req.params.id); 57 | const querySelect = req.query.select; 58 | let select: Prisma.CategorySelect | undefined; 59 | 60 | // If select specific fields, response only selected query 61 | if (querySelect) { 62 | select = selectedQuery(querySelect as string); 63 | } 64 | 65 | const category = await prisma.category.findUnique({ 66 | where: { id }, 67 | select, 68 | }); 69 | 70 | // Throws an error if category does not exists 71 | if (!category) { 72 | return next(new ErrorResponse(resource404Error("category"), 404)); 73 | } 74 | 75 | res.status(200).json({ 76 | success: true, 77 | data: category, 78 | }); 79 | }); 80 | 81 | /** 82 | * Create a new category 83 | * @route POST /api/v1/categories 84 | * @access Private (admin) 85 | */ 86 | export const createCategory = asyncHandler(async (req, res, next) => { 87 | const queryName: string | undefined = req.body.name; 88 | const id: number | undefined = parseInt(req.body.id) || undefined; 89 | const description: string | undefined = req.body.description; 90 | const thumbnailImage: string | undefined = req.body.thumbnailImage; 91 | let name: string | undefined; 92 | 93 | // Throws an error if name field is not specified 94 | const hasError = checkRequiredFields({ name: queryName }, next); 95 | if (hasError !== false) return hasError; 96 | 97 | // Trim the name and change it to lower-case 98 | name = (queryName as string).trim().toLowerCase(); 99 | 100 | // Create a category in prisma client 101 | const category = await prisma.category.create({ 102 | data: { 103 | id: id as number, 104 | name: name as string, 105 | description, 106 | thumbnailImage, 107 | }, 108 | }); 109 | 110 | res.status(201).json({ 111 | success: true, 112 | location: `${req.protocol}://${req.get("host")}${req.baseUrl}/${ 113 | category.id 114 | }`, 115 | data: category, 116 | }); 117 | }); 118 | 119 | /** 120 | * Delete a category 121 | * @route DELETE /api/v1/categories/:id 122 | * @access Private (admin) 123 | */ 124 | export const deleteCategory = asyncHandler(async (req, res, next) => { 125 | const id = parseInt(req.params.id); 126 | 127 | await prisma.category.delete({ 128 | where: { id }, 129 | }); 130 | 131 | res.status(204).json({ 132 | success: true, 133 | data: [], 134 | }); 135 | }); 136 | 137 | /** 138 | * Update category 139 | * @route PUT /api/v1/categories/:id 140 | * @access Private 141 | */ 142 | export const updateCategory = asyncHandler(async (req, res, next) => { 143 | const id = parseInt(req.params.id); 144 | const name: string | undefined = req.body.name; 145 | const description: string | undefined = req.body.description; 146 | const thumbnailImage: string | undefined = req.body.thumbnailImage; 147 | 148 | const category = await prisma.category.update({ 149 | where: { id }, 150 | data: { 151 | name, 152 | description, 153 | thumbnailImage, 154 | updatedAt: new Date().toISOString(), 155 | }, 156 | }); 157 | 158 | res.status(200).json({ 159 | success: true, 160 | data: category, 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /controllers/customers.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import app from "../app"; 3 | import "jest-extended"; 4 | import { errorTypes, resource404Error } from "../utils/errorObject"; 5 | import prisma from "../prisma/client"; 6 | 7 | const url = "/api/v1/customers"; 8 | 9 | let authToken = ""; 10 | 11 | // Login as superadmin 12 | const adminLogin = async () => { 13 | const response = await request(app) 14 | .post(`/api/v1/admins/login`) 15 | .send({ email: "superadmin@gmail.com", password: "superadmin" }); 16 | authToken = response.body.token; 17 | }; 18 | 19 | beforeAll(() => adminLogin()); 20 | 21 | describe("Customers", () => { 22 | describe("Get Customers", () => { 23 | it("GET /customers --> should return all customers", async () => { 24 | const response = await request(app) 25 | .get(url) 26 | .set("Authorization", "Bearer " + authToken) 27 | .expect("Content-Type", /json/) 28 | .expect(200); 29 | 30 | expect(response.body.success).toBe(true); 31 | expect(response.body.count).toBeGreaterThanOrEqual(0); 32 | expect(response.body.data).toEqual( 33 | expect.arrayContaining([ 34 | { 35 | id: expect.any(Number), 36 | fullname: expect.any(String), 37 | email: expect.any(String), 38 | shippingAddress: expect.any(String), 39 | phone: expect.toBeOneOf([expect.any(String), null]), 40 | createdAt: expect.any(String), 41 | updatedAt: expect.toBeOneOf([expect.any(String), null]), 42 | }, 43 | ]) 44 | ); 45 | }); 46 | 47 | it("GET /customers/:id --> should return specific customer", async () => { 48 | const response = await request(app) 49 | .get(`${url}/3`) 50 | .set("Authorization", "Bearer " + authToken) 51 | .expect("Content-Type", /json/) 52 | .expect(200); 53 | 54 | expect(response.body.success).toBe(true); 55 | expect(response.body.data).toEqual({ 56 | id: expect.any(Number), 57 | fullname: expect.any(String), 58 | email: expect.any(String), 59 | shippingAddress: expect.any(String), 60 | phone: expect.toBeOneOf([expect.any(String), null]), 61 | createdAt: expect.any(String), 62 | updatedAt: expect.toBeOneOf([expect.any(String), null]), 63 | }); 64 | }); 65 | 66 | it("GET /customers/:id --> should throw 404 if customer not found", async () => { 67 | const response = await request(app) 68 | .get(`${url}/999`) 69 | .set("Authorization", "Bearer " + authToken) 70 | .expect("Content-Type", /json/) 71 | .expect(404); 72 | 73 | expect(response.body.success).toBe(false); 74 | expect(response.body.error).toEqual(resource404Error("customer")); 75 | }); 76 | }); 77 | 78 | describe("Delete Customer", () => { 79 | it("DELETE /customers/:id --> should delete a customer", async () => { 80 | // create a customer to be deleted 81 | const customer = await prisma.customer.create({ 82 | data: { 83 | email: "testuser3@gmail.com", 84 | fullname: "testuser", 85 | password: "somerandompwd", 86 | shippingAddress: "someaddr", 87 | createdAt: new Date().toISOString(), 88 | }, 89 | }); 90 | const response = await request(app) 91 | .delete(`${url}/${customer.id}`) 92 | .set("Authorization", "Bearer " + authToken) 93 | .expect(204); 94 | }); 95 | 96 | it("DELETE /customers/:id --> should return 404 if user not found", async () => { 97 | const response = await request(app) 98 | .delete(`${url}/9999`) 99 | .set("Authorization", "Bearer " + authToken) 100 | .expect("Content-Type", /json/) 101 | .expect(404); 102 | 103 | expect(response.body.success).toBe(false); 104 | expect(response.body.error).toEqual({ 105 | status: 404, 106 | type: errorTypes.notFound, 107 | message: "record to delete does not exist.", 108 | }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /controllers/customers.ts: -------------------------------------------------------------------------------- 1 | import asyncHandler from "../middlewares/asyncHandler"; 2 | import prisma from "../prisma/client"; 3 | import { resource404Error } from "../utils/errorObject"; 4 | import ErrorResponse from "../utils/errorResponse"; 5 | 6 | /** 7 | * Get all customers 8 | * @route GET /api/v1/customers 9 | * @access Private 10 | */ 11 | export const getCustomers = asyncHandler(async (req, res, next) => { 12 | const customers = await prisma.customer.findMany({ 13 | // prisma desn't provide exclude yet, thus I have to 14 | // specify these fields to exclude some fields like password. sucks! 15 | select: { 16 | id: true, 17 | fullname: true, 18 | email: true, 19 | shippingAddress: true, 20 | phone: true, 21 | createdAt: true, 22 | updatedAt: true, 23 | }, 24 | }); 25 | 26 | res.status(200).json({ 27 | success: true, 28 | count: customers.length, 29 | data: customers, 30 | }); 31 | }); 32 | 33 | /** 34 | * Get specific customer 35 | * @route GET /api/v1/customers/:id 36 | * @access Private 37 | */ 38 | export const getCustomer = asyncHandler(async (req, res, next) => { 39 | const id = parseInt(req.params.id); 40 | 41 | const customer = await prisma.customer.findUnique({ 42 | where: { id }, 43 | select: { 44 | id: true, 45 | fullname: true, 46 | email: true, 47 | shippingAddress: true, 48 | phone: true, 49 | createdAt: true, 50 | updatedAt: true, 51 | }, 52 | }); 53 | 54 | // Throws 404 error if customer not found 55 | if (!customer) { 56 | return next(new ErrorResponse(resource404Error("customer"), 404)); 57 | } 58 | 59 | res.status(200).json({ 60 | success: true, 61 | data: customer, 62 | }); 63 | }); 64 | 65 | /** 66 | * Delete customer 67 | * @route DEETE /api/v1/customers/:id 68 | * @access Private 69 | */ 70 | export const deleteCustomer = asyncHandler(async (req, res, next) => { 71 | const id = parseInt(req.params.id); 72 | 73 | await prisma.customer.delete({ 74 | where: { id }, 75 | }); 76 | 77 | res.status(204).json({ 78 | success: true, 79 | data: [], 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /controllers/orders.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../prisma/client"; 2 | import asyncHandler from "../middlewares/asyncHandler"; 3 | import ErrorResponse from "../utils/errorResponse"; 4 | import { 5 | checkDeliveryType, 6 | checkPaymentType, 7 | checkRequiredFields, 8 | } from "../utils/helperFunctions"; 9 | import { 10 | DeliveryType, 11 | PaymentType, 12 | OrderDetail, 13 | Product, 14 | } from ".prisma/client"; 15 | import { 16 | defaultError, 17 | deliveryTypeError, 18 | invalidArgError, 19 | paymentTypeError, 20 | resource404Error, 21 | } from "../utils/errorObject"; 22 | import sendMail from "../utils/sendEmail"; 23 | import emailTemplate from "../utils/emailTemplate"; 24 | import { Decimal } from "@prisma/client/runtime"; 25 | 26 | /** 27 | * Get all orders 28 | * @route GET /api/v1/orders 29 | * @access Private (superadmin) 30 | */ 31 | export const getOrders = asyncHandler(async (req, res, next) => { 32 | const orders = await prisma.order.findMany({ 33 | include: { 34 | orders: true, 35 | }, 36 | }); 37 | res.status(200).json({ 38 | success: true, 39 | count: orders.length, 40 | data: orders, 41 | }); 42 | }); 43 | 44 | /** 45 | * Get specific order 46 | * @route GET /api/v1/orders 47 | * @access Private (superadmin) 48 | */ 49 | export const getOrder = asyncHandler(async (req, res, next) => { 50 | const id = parseInt(req.params.id); 51 | 52 | const order = await prisma.order.findMany({ 53 | where: { orderNumber: id }, 54 | include: { 55 | orders: true, 56 | }, 57 | }); 58 | 59 | if (order.length === 0) { 60 | return next(new ErrorResponse(resource404Error("order"), 400)); 61 | } 62 | 63 | res.status(200).json({ 64 | success: true, 65 | data: order, 66 | }); 67 | }); 68 | 69 | /** 70 | * Get all orders details 71 | * @route PATCH /api/v1/orders 72 | * @access Testing purpose only 73 | */ 74 | export const getOrderDetails = asyncHandler(async (req, res, next) => { 75 | const orderDetails = await prisma.orderDetail.findMany(); 76 | res.status(200).json({ 77 | success: true, 78 | count: orderDetails.length, 79 | data: orderDetails, 80 | }); 81 | }); 82 | 83 | /** 84 | * Create new order 85 | * @route POST /api/v1/orders 86 | * @access Public 87 | */ 88 | export const createOrder = asyncHandler(async (req, res, next) => { 89 | type Products = { 90 | id: number; 91 | quantity: number; 92 | }[]; 93 | 94 | const customerId: string | undefined = req.body.customerId; 95 | const shippingAddress: string | undefined = req.body.shippingAddress; 96 | const totalPrice: string | undefined = req.body.totalPrice; 97 | let deliveryDate: string | Date | undefined = req.body.deliveryDate; 98 | const paymentType: PaymentType | undefined = req.body.paymentType; // optional 99 | const deliveryType: DeliveryType | undefined = req.body.deliveryType; // optional 100 | const products: Products | undefined = req.body.products; // [ { id: 1002, quantity: 1 }, { id: 1020, quantity: 3 }] 101 | const sendEmail: boolean | undefined = req.body.sendEmail; // optional 102 | 103 | const requiredFields = { 104 | customerId, 105 | shippingAddress, 106 | totalPrice, 107 | deliveryDate: deliveryDate as string, 108 | }; 109 | 110 | const hasError = checkRequiredFields(requiredFields, next); 111 | if (hasError !== false) return hasError; 112 | 113 | if (!products || products.length < 1) { 114 | return next( 115 | new ErrorResponse( 116 | invalidArgError([ 117 | { 118 | code: "missingProducts", 119 | message: "products cannot be empty", 120 | }, 121 | ]), 122 | 400 123 | ) 124 | ); 125 | } 126 | 127 | // Check payment type is either "CASH_ON_DELIVERY" or "BANK_TRANSFER" 128 | if (paymentType) { 129 | if (!checkPaymentType(paymentType)) 130 | return next(new ErrorResponse(paymentTypeError, 400)); 131 | } 132 | 133 | // Check payment type is either "STORE_PICKUP", "YANGON", "OTHERS" 134 | if (deliveryType) { 135 | if (!checkDeliveryType(deliveryType)) 136 | return next(new ErrorResponse(deliveryTypeError, 400)); 137 | } 138 | 139 | deliveryDate = new Date(deliveryDate as string); 140 | 141 | // Create an order 142 | const order = await prisma.order.create({ 143 | data: { 144 | customerId: parseInt(customerId!), 145 | shippingAddress: shippingAddress as string, 146 | deliveryDate: deliveryDate, 147 | totalPrice: parseFloat(totalPrice as string), 148 | paymentType, 149 | deliveryType, 150 | }, 151 | include: { 152 | customer: true, 153 | }, 154 | }); 155 | 156 | type OrderDetailData = { 157 | orderNumber: number; 158 | productId: number; 159 | quantity: number | undefined; 160 | }[]; 161 | 162 | const orderDetailData: OrderDetailData = products.map(({ id, quantity }) => ({ 163 | orderNumber: order.orderNumber, 164 | productId: id, 165 | quantity: quantity, 166 | })); 167 | 168 | const orderDetail = await prisma.orderDetail.createMany({ 169 | data: orderDetailData, 170 | skipDuplicates: true, 171 | }); 172 | 173 | let createdOrderDetail: (OrderDetail & { 174 | product: Product; 175 | })[] = []; 176 | if (orderDetail) { 177 | createdOrderDetail = await prisma.orderDetail.findMany({ 178 | where: { 179 | orderNumber: order.orderNumber, 180 | }, 181 | include: { 182 | product: true, 183 | }, 184 | }); 185 | } 186 | 187 | // if order and orderDetail succeed 188 | // and sendEmail option is true 189 | if (order && orderDetail && sendEmail) { 190 | try { 191 | // get purchased items in formatted array 192 | const items = createdOrderDetail.map((orderItem) => ({ 193 | name: orderItem.product.name, 194 | qty: orderItem.quantity, 195 | price: "" + orderItem.product.price, 196 | })); 197 | 198 | // invoke emailTemplate function and 199 | // store returned html in message variable 200 | const message = emailTemplate( 201 | order.orderNumber, 202 | order.totalPrice, 203 | order.shippingAddress, 204 | "" + deliveryDate, 205 | items 206 | ); 207 | 208 | // send email to user 209 | await sendMail({ 210 | email: order.customer.email, 211 | subject: "Haru Fashion Order Received", 212 | message, 213 | }); 214 | res.status(201).json({ 215 | success: true, 216 | data: order, 217 | orderDetail: createdOrderDetail, 218 | }); 219 | } catch (err) { 220 | // Log error 221 | console.error(err); 222 | 223 | return next(new ErrorResponse(defaultError, 500)); 224 | } 225 | } 226 | 227 | res.status(201).json({ 228 | success: true, 229 | data: order, 230 | orderDetail: createdOrderDetail, 231 | }); 232 | }); 233 | 234 | /** 235 | * Delete order by id 236 | * @route DELETE /api/v1/orders/:id 237 | * @access Private (admin) 238 | */ 239 | export const deleteOrder = asyncHandler(async (req, res, next) => { 240 | const id = parseInt(req.params.id); 241 | 242 | await prisma.order.delete({ 243 | where: { orderNumber: id }, 244 | }); 245 | 246 | res.status(204).json({ 247 | success: true, 248 | data: [], 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /controllers/products.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import app from "../app"; 3 | import "jest-sorted"; 4 | import { 5 | errorTypes, 6 | invalidQuery, 7 | resource404Error, 8 | } from "../utils/errorObject"; 9 | import prisma from "../prisma/client"; 10 | 11 | const url = `/api/v1/products`; 12 | 13 | let authToken = ""; 14 | 15 | const adminLogin = async () => { 16 | const response = await request(app) 17 | .post(`/api/v1/admins/login`) 18 | .send({ email: "superadmin@gmail.com", password: "superadmin" }); 19 | authToken = response.body.token; 20 | }; 21 | 22 | beforeAll(() => adminLogin()); 23 | 24 | describe("Product Controler", () => { 25 | describe("Get Products", () => { 26 | it("GET /products --> return all products", async () => { 27 | const response = await request(app) 28 | .get(url) 29 | .expect("Content-Type", /json/) 30 | .expect(200); 31 | 32 | expect(response.body.count).not.toBeUndefined(); 33 | expect(response.body.success).toBeTruthy(); 34 | expect(response.body.success).toBe(true); 35 | expect(response.body).toEqual({ 36 | success: true, 37 | count: expect.any(Number), 38 | data: expect.arrayContaining([ 39 | expect.objectContaining({ 40 | id: expect.any(Number), 41 | name: expect.any(String), 42 | price: expect.any(String), 43 | discountPercent: expect.any(Number || null), 44 | description: expect.any(String), 45 | detail: expect.any(String || null), 46 | categoryId: expect.any(Number), 47 | // // category: expect.any(Array), 48 | image1: expect.any(String), 49 | image2: expect.any(String), 50 | stock: expect.any(Number), 51 | createdAt: expect.any(String), 52 | updatedAt: null, 53 | }), 54 | ]), 55 | }); 56 | }); 57 | 58 | it("GET /products --> select name, price", async () => { 59 | const response = await request(app) 60 | .get(url) 61 | .query({ select: "name,price" }) 62 | .expect("Content-Type", /json/) 63 | .expect(200); 64 | 65 | expect(response.body.success).toBeTruthy(); 66 | expect(response.body.count).toEqual(expect.any(Number)); 67 | expect(response.body.data).toEqual( 68 | expect.arrayContaining([ 69 | { 70 | name: expect.any(String), 71 | price: expect.any(String), 72 | }, 73 | ]) 74 | ); 75 | }); 76 | 77 | it("GET /products --> order_by name", async () => { 78 | const response = await request(app) 79 | .get(url) 80 | .query({ order_by: "name" }) 81 | .expect("Content-Type", /json/) 82 | .expect(200); 83 | 84 | expect(response.body.success).toBeTruthy(); 85 | expect(response.body.count).toEqual(expect.any(Number)); 86 | expect(response.body.data).toBeSortedBy("name"); 87 | }); 88 | 89 | // Pagination skip & take 90 | it("GET /products --> pagination | skip 40, limit 10", async () => { 91 | const response = await request(app) 92 | .get(url) 93 | .query({ limit: 10, offset: 50 }) 94 | .expect("Content-Type", /json/) 95 | .expect(200); 96 | 97 | expect(response.body.success).toBeTruthy(); 98 | expect(response.body.count).toEqual(expect.any(Number)); 99 | expect(response.body.data.length).toBe(10); 100 | expect(response.body.data).toEqual( 101 | expect.arrayContaining([ 102 | expect.objectContaining({ id: 51 }), 103 | expect.objectContaining({ id: 60 }), 104 | ]) 105 | ); 106 | }); 107 | 108 | // Price equals gt lte 109 | it("GET /products --> price gte 50 & lt 100", async () => { 110 | const response = await request(app) 111 | .get(url) 112 | .query({ price: ["gte:50", "lt:100"] }) 113 | .expect("Content-Type", /json/) 114 | .expect(200); 115 | 116 | expect(response.body.success).toBeTruthy(); 117 | expect(response.body.count).toEqual(expect.any(Number)); 118 | for (let obj of response.body.data) { 119 | expect(parseFloat(obj.price)).toBeGreaterThanOrEqual(50); 120 | expect(parseFloat(obj.price)).toBeLessThan(100); 121 | } 122 | }); 123 | 124 | // Price greater than 125 | it("GET /products --> price gt 50", async () => { 126 | const response = await request(app) 127 | .get(url) 128 | .query({ price: "gt:50" }) 129 | .expect("Content-Type", /json/) 130 | .expect(200); 131 | 132 | expect(response.body.success).toBeTruthy(); 133 | expect(response.body.count).toEqual(expect.any(Number)); 134 | for (let obj of response.body.data) { 135 | expect(parseFloat(obj.price)).toBeGreaterThan(50); 136 | } 137 | }); 138 | 139 | // Stock equals 58 140 | it("GET /products --> stock equals 58", async () => { 141 | const response = await request(app) 142 | .get(url) 143 | .query({ stock: "equals:58" }) 144 | .expect("Content-Type", /json/) 145 | .expect(200); 146 | 147 | expect(response.body.success).toBeTruthy(); 148 | expect(response.body.count).toEqual(expect.any(Number)); 149 | for (let obj of response.body.data) { 150 | expect(parseFloat(obj.stock)).toEqual(58); 151 | } 152 | }); 153 | 154 | // Error if more stock or price param is more than twice 155 | it("GET /products --> error price if same param > 2", async () => { 156 | const response = await request(app) 157 | .get(url) 158 | .query({ price: ["gte:50", "lt:100", "gt:60"] }) 159 | .expect("Content-Type", /json/) 160 | .expect(400); 161 | 162 | expect(response.body.success).toBeFalsy(); 163 | expect(response.body.count).toBeUndefined(); 164 | expect(response.body.error).toEqual({ 165 | status: 400, 166 | type: errorTypes.badRequest, 167 | message: "same parameter cannot be more than twice", 168 | }); 169 | }); 170 | 171 | // Error if more stock or stock param is more than twice 172 | it("GET /products --> error stock if same param > 2", async () => { 173 | const response = await request(app) 174 | .get(url) 175 | .query({ stock: ["gte:50", "lt:100", "gt:60"] }) 176 | .expect("Content-Type", /json/) 177 | .expect(400); 178 | 179 | expect(response.body.success).toBeFalsy(); 180 | expect(response.body.count).toBeUndefined(); 181 | expect(response.body.error).toEqual({ 182 | status: 400, 183 | type: errorTypes.badRequest, 184 | message: "same parameter cannot be more than twice", 185 | }); 186 | }); 187 | 188 | // Search Proucts 189 | it("GET /products/search --> return searched items", async () => { 190 | const response = await request(app) 191 | .get(`${url}/search`) 192 | .query({ q: "Aerified" }) 193 | .expect("Content-Type", /json/) 194 | .expect(200); 195 | 196 | expect(response.body.success).toBeTruthy(); 197 | expect(response.body.count).toEqual(1); 198 | expect(response.body.data).toEqual([ 199 | expect.objectContaining({ 200 | name: "Aerified", 201 | }), 202 | ]); 203 | }); 204 | 205 | // Select Specific product including its related category 206 | 207 | // Get Specific product 208 | it("GET /products/:id --> return specific product", async () => { 209 | const response = await request(app) 210 | .get(`${url}/5`) 211 | .expect("Content-Type", /json/) 212 | .expect(200); 213 | 214 | expect(response.body.success).toBeTruthy(); 215 | expect(response.body.data.id).toBe(5); 216 | }); 217 | 218 | // 404 Error if product not found 219 | it("GET /products/:id --> 404 Error if not found", async () => { 220 | const response = await request(app) 221 | .get(`${url}/999`) 222 | .expect("Content-Type", /json/) 223 | .expect(404); 224 | 225 | expect(response.body.success).toBeFalsy(); 226 | expect(response.body.error).toEqual(resource404Error("product")); 227 | }); 228 | 229 | // include related categories 230 | it("GET /products/:id --> include related category", async () => { 231 | const response = await request(app) 232 | .get(`${url}/5`) 233 | .query({ include: "category" }) 234 | .expect("Content-Type", /json/) 235 | .expect(200); 236 | 237 | expect(response.body.success).toBeTruthy(); 238 | expect(response.body.data.category).toEqual( 239 | expect.objectContaining({ 240 | id: expect.any(Number), 241 | name: expect.any(String), 242 | }) 243 | ); 244 | }); 245 | 246 | // error if include value is not "category" 247 | it("GET /products/:id --> validation for include: 'category'", async () => { 248 | const response = await request(app) 249 | .get(`${url}/5`) 250 | .query({ include: "categories" }) 251 | .expect("Content-Type", /json/) 252 | .expect(400); 253 | 254 | expect(response.body.success).toBe(false); 255 | expect(response.body.error).toEqual(invalidQuery); 256 | }); 257 | }); 258 | 259 | describe("Create Product", () => { 260 | it("POST /products --> create a new product", async () => { 261 | const newProduct = { 262 | name: "test product", 263 | price: "500", 264 | description: "this is test product", 265 | detail: "this is product detail. Just a testing", 266 | categoryId: 3, 267 | image1: "imageurl.com/png", 268 | image2: "imageur2.com/png", 269 | stock: "10", 270 | }; 271 | const response = await request(app) 272 | .post(url) 273 | .set("Authorization", "Bearer " + authToken) 274 | .send(newProduct) 275 | .expect("Content-Type", /json/) 276 | .expect(201); 277 | 278 | expect(response.body.success).toBe(true); 279 | expect(response.body.data).toEqual({ 280 | id: expect.any(Number), 281 | name: expect.any(String), 282 | price: expect.any(String), 283 | discountPercent: expect.any(Number || null), 284 | description: expect.any(String), 285 | detail: expect.any(String || null), 286 | categoryId: expect.any(Number), 287 | // category: expect.any(Array), 288 | image1: expect.any(String), 289 | image2: expect.any(String), 290 | stock: expect.any(Number), 291 | createdAt: expect.any(String), 292 | updatedAt: null, 293 | }); 294 | }); 295 | 296 | it("POST /products --> throws error if required field is missing", async () => { 297 | const response = await request(app) 298 | .post(url) 299 | .set("Authorization", "Bearer " + authToken) 300 | .expect("Content-Type", /json/) 301 | .expect(400); 302 | 303 | expect(response.body.success).toBe(false); 304 | // expect(response.body.error).toEqual(missingField("name")); 305 | expect(response.body.error).toEqual({ 306 | status: 400, 307 | type: "invalidArgument", 308 | message: "invalid one or more argument(s)", 309 | detail: [ 310 | { 311 | code: "missingName", 312 | message: "name field is missing", 313 | }, 314 | { 315 | code: "missingPrice", 316 | message: "price field is missing", 317 | }, 318 | { 319 | code: "missingDescription", 320 | message: "description field is missing", 321 | }, 322 | { 323 | code: "missingImage1", 324 | message: "image1 field is missing", 325 | }, 326 | { 327 | code: "missingImage2", 328 | message: "image2 field is missing", 329 | }, 330 | ], 331 | }); 332 | }); 333 | 334 | it("POST /products --> throws error if categoryId is invalid", async () => { 335 | const reqBody = { 336 | name: "Wallie", 337 | price: "1500", 338 | description: "this is just a description", 339 | image1: "image1.png", 340 | image2: "image2.png", 341 | categoryId: "999", 342 | }; 343 | const response = await request(app) 344 | .post(url) 345 | .set("Authorization", "Bearer " + authToken) 346 | .send(reqBody) 347 | .expect("Content-Type", /json/) 348 | .expect(400); 349 | 350 | expect(response.body.success).toBe(false); 351 | expect(response.body.error).toEqual(invalidCategoryIdError); 352 | }); 353 | 354 | it("POST /products --> throws error if price field is invalid", async () => { 355 | const reqBody = { 356 | name: "Wallie", 357 | price: "some string", 358 | description: "this is just a description", 359 | image1: "image1.png", 360 | image2: "image2.png", 361 | categoryId: "2", 362 | }; 363 | const response = await request(app) 364 | .post(url) 365 | .set("Authorization", "Bearer " + authToken) 366 | .send(reqBody) 367 | .expect("Content-Type", /json/) 368 | .expect(400); 369 | 370 | expect(response.body.success).toBe(false); 371 | expect(response.body.error).toEqual(invalidPriceError); 372 | }); 373 | 374 | it("POST /products --> throws error if stock field is invalid", async () => { 375 | const reqBody = { 376 | name: "Wallie", 377 | price: "300", 378 | description: "this is just a description", 379 | image1: "image1.png", 380 | image2: "image2.png", 381 | stock: "some string", 382 | categoryId: 2, 383 | }; 384 | const response = await request(app) 385 | .post(url) 386 | .set("Authorization", "Bearer " + authToken) 387 | .send(reqBody) 388 | .expect("Content-Type", /json/) 389 | .expect(400); 390 | 391 | expect(response.body.success).toBe(false); 392 | expect(response.body.error).toEqual(invalidStockError); 393 | 394 | const reqBody2 = { ...reqBody, stock: "23.22" }; 395 | const response2 = await request(app) 396 | .post(url) 397 | .set("Authorization", "Bearer " + authToken) 398 | .send(reqBody2) 399 | .expect("Content-Type", /json/) 400 | .expect(400); 401 | 402 | expect(response2.body.success).toBe(false); 403 | expect(response2.body.error).toEqual(invalidStockError); 404 | }); 405 | }); 406 | 407 | describe("Update Product", () => { 408 | const reqBody = { 409 | name: "updated category", 410 | price: "100", 411 | discountPercent: "5", 412 | description: "this is updated description", 413 | detail: "this is updated detail", 414 | categoryId: "2", 415 | image1: "image1.png", 416 | image2: "image2.png", 417 | stock: "20", 418 | }; 419 | 420 | it("PUT /products/:id --> should update a product", async () => { 421 | const response = await request(app) 422 | .put(`${url}/3`) 423 | .set("Authorization", "Bearer " + authToken) 424 | .send(reqBody) 425 | .expect("Content-Type", /json/) 426 | .expect(200); 427 | 428 | expect(response.body.success).toBe(true); 429 | expect(response.body.data).toEqual({ 430 | ...reqBody, 431 | id: 3, 432 | discountPercent: 5, 433 | stock: 20, 434 | categoryId: 2, 435 | createdAt: expect.any(String), 436 | updatedAt: expect.any(String), 437 | }); 438 | }); 439 | 440 | it("PUT /products/:id --> 404 if product not found", async () => { 441 | const response = await request(app) 442 | .put(`${url}/9999`) 443 | .set("Authorization", "Bearer " + authToken) 444 | .send(reqBody) 445 | .expect("Content-Type", /json/) 446 | .expect(404); 447 | 448 | expect(response.body.success).toBe(false); 449 | expect(response.body.error).toEqual({ 450 | status: 404, 451 | type: errorTypes.notFound, 452 | message: "record to update not found.", 453 | }); 454 | }); 455 | 456 | it("PUT /products/:id --> price should be positive float", async () => { 457 | const response = await request(app) 458 | .put(`${url}/3`) 459 | .set("Authorization", "Bearer " + authToken) 460 | .send({ ...reqBody, price: "some string" }) 461 | .expect("Content-Type", /json/) 462 | .expect(400); 463 | expect(response.body.success).toBe(false); 464 | expect(response.body.error).toEqual(invalidPriceError); 465 | 466 | const response2 = await request(app) 467 | .put(`${url}/3`) 468 | .set("Authorization", "Bearer " + authToken) 469 | .send({ ...reqBody, price: "-100" }) 470 | .expect("Content-Type", /json/) 471 | .expect(400); 472 | expect(response2.body.success).toBe(false); 473 | expect(response2.body.error).toEqual(invalidPriceError); 474 | }); 475 | 476 | it("PUT /products/:id --> stock should be positive integer", async () => { 477 | const response = await request(app) 478 | .put(`${url}/3`) 479 | .set("Authorization", "Bearer " + authToken) 480 | .send({ ...reqBody, stock: "some string" }) 481 | .expect("Content-Type", /json/) 482 | .expect(400); 483 | 484 | expect(response.body.success).toBe(false); 485 | expect(response.body.error).toEqual(invalidStockError); 486 | 487 | const response2 = await request(app) 488 | .put(`${url}/3`) 489 | .set("Authorization", "Bearer " + authToken) 490 | .send({ ...reqBody, stock: "-100" }) 491 | .expect("Content-Type", /json/) 492 | .expect(400); 493 | 494 | expect(response2.body.success).toBe(false); 495 | expect(response2.body.error).toEqual(invalidStockError); 496 | }); 497 | 498 | it("PUT /products/:id --> category id should be existing category", async () => { 499 | const response = await request(app) 500 | .put(`${url}/3`) 501 | .set("Authorization", "Bearer " + authToken) 502 | .send({ ...reqBody, categoryId: "999" }) 503 | .expect("Content-Type", /json/) 504 | .expect(400); 505 | 506 | expect(response.body.success).toBe(false); 507 | expect(response.body.error).toEqual(invalidCategoryIdError); 508 | }); 509 | }); 510 | 511 | describe("Delete Product", () => { 512 | it("DELETE /products/:id --> should delete a product", async () => { 513 | const latestProduct = await prisma.product.findFirst({ 514 | select: { 515 | id: true, 516 | }, 517 | orderBy: { 518 | id: "desc", 519 | }, 520 | }); 521 | const response = await request(app) 522 | .delete(`${url}/${latestProduct!.id}`) 523 | .set("Authorization", "Bearer " + authToken) 524 | .expect(204); 525 | 526 | expect(response.status).toBe(204); 527 | }); 528 | 529 | it("DELETE /products/:id --> should throw 404 if not found", async () => { 530 | const response = await request(app) 531 | .delete(`${url}/9999`) 532 | .set("Authorization", "Bearer " + authToken) 533 | .expect("Content-Type", /json/) 534 | .expect(404); 535 | 536 | expect(response.body.success).toBe(false); 537 | expect(response.body.error).toEqual({ 538 | status: 404, 539 | type: errorTypes.notFound, 540 | message: "record to delete does not exist.", 541 | }); 542 | }); 543 | }); 544 | }); 545 | 546 | /* ========================= Errors ============================ */ 547 | const invalidStockError = { 548 | status: 400, 549 | type: errorTypes.invalidArgument, 550 | message: "invalid stock field", 551 | detail: [ 552 | { 553 | code: "invalidStock", 554 | message: `stock field must only be valid integer`, 555 | }, 556 | ], 557 | }; 558 | 559 | const invalidPriceError = { 560 | status: 400, 561 | type: errorTypes.invalidArgument, 562 | message: "invalid price field", 563 | detail: [ 564 | { 565 | code: "invalidPrice", 566 | message: `price field must only be valid number`, 567 | }, 568 | ], 569 | }; 570 | 571 | const invalidCategoryIdError = { 572 | status: 400, 573 | type: errorTypes.invalidArgument, 574 | message: "invalid category id", 575 | detail: [ 576 | { 577 | code: "invalidCategory", 578 | message: `there is no category with id 999`, 579 | }, 580 | ], 581 | }; 582 | -------------------------------------------------------------------------------- /controllers/products.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../prisma/client"; 2 | import asyncHandler from "../middlewares/asyncHandler"; 3 | import { 4 | checkRequiredFields, 5 | filteredQty, 6 | isIntegerAndPositive, 7 | orderedQuery, 8 | ProductSelectType, 9 | selectAllProductField, 10 | selectedQuery, 11 | } from "../utils/helperFunctions"; 12 | import { Prisma } from ".prisma/client"; 13 | import ErrorResponse from "../utils/errorResponse"; 14 | import errorObj, { 15 | errObjType, 16 | errorTypes, 17 | invalidQuery, 18 | resource404Error, 19 | } from "../utils/errorObject"; 20 | 21 | /** 22 | * Get all products 23 | * @route GET /api/v1/products 24 | * @access Public 25 | */ 26 | export const getProducts = asyncHandler(async (req, res, next) => { 27 | type FilteredType = { [key: string]: number }; 28 | 29 | // requested queries 30 | const querySelect = req.query.select; 31 | const queryInclude = req.query.include; 32 | const queryOrderBy = req.query.order_by; 33 | const queryOffset = req.query.offset; 34 | const queryLimit = req.query.limit; 35 | const queryPrice = req.query.price; 36 | const queryStock = req.query.stock; 37 | const queryCategory = req.query.category; 38 | 39 | // init variables 40 | let select: Prisma.ProductSelect | ProductSelectType | undefined; 41 | let orderBy: 42 | | Prisma.Enumerable 43 | | undefined; 44 | let skip: number | undefined; 45 | let take: number | undefined; 46 | let price: FilteredType[] = []; 47 | let stock: FilteredType[] = []; 48 | let categoryId: number | undefined; 49 | 50 | // return error if include field is not tags or category 51 | if (queryInclude) { 52 | const includedFields = (queryInclude as string).split(","); 53 | let error = false; 54 | includedFields.forEach((field) => { 55 | if (field !== "tags" && field !== "category") { 56 | error = true; 57 | } 58 | }); 59 | 60 | if (error) { 61 | return next( 62 | new ErrorResponse( 63 | { 64 | status: 400, 65 | type: errorTypes.badRequest, 66 | message: "include field is not correct", 67 | }, 68 | 400 69 | ) 70 | ); 71 | } 72 | } 73 | 74 | // if select & !include 75 | if (querySelect && !queryInclude) { 76 | select = selectedQuery(querySelect as string); 77 | } 78 | // if select & include 79 | else if (querySelect && queryInclude) { 80 | const selectedFields = selectedQuery(querySelect as string); 81 | const includedFields = selectedQuery(queryInclude as string); 82 | select = { 83 | ...selectedFields, 84 | ...includedFields, 85 | }; 86 | } 87 | // if include & !select 88 | else if (!querySelect && queryInclude) { 89 | const selectAll = selectAllProductField(); 90 | const includedFields = selectedQuery(queryInclude as string); 91 | select = { 92 | ...selectAll, 93 | ...includedFields, 94 | }; 95 | } 96 | 97 | // if order_by param is requested 98 | if (queryOrderBy) { 99 | orderBy = orderedQuery(queryOrderBy as string); 100 | } 101 | 102 | // if offset param is requested 103 | if (queryOffset) { 104 | skip = parseInt(queryOffset as string); 105 | } 106 | 107 | // if limit param is requested 108 | if (queryLimit) { 109 | take = parseInt(queryLimit as string); 110 | } 111 | 112 | // error obj for price and stock 113 | const errObj: errObjType = { 114 | status: 400, 115 | type: errorTypes.badRequest, 116 | message: "same parameter cannot be more than twice", 117 | }; 118 | 119 | // if price param is requested 120 | if (queryPrice) { 121 | if (typeof queryPrice !== "string" && (queryPrice as string[]).length > 2) { 122 | return next(new ErrorResponse(errObj, 400)); 123 | } 124 | price = filteredQty(queryPrice as string | string[]); 125 | } 126 | 127 | // if stock param is requested 128 | if (queryStock) { 129 | if (typeof queryStock !== "string" && (queryStock as string[]).length > 2) { 130 | return next(new ErrorResponse(errObj, 400)); 131 | } 132 | stock = filteredQty(queryStock as string | string[]); 133 | } 134 | 135 | // if req products with certain category 136 | if (queryCategory) { 137 | const category = await prisma.category.findUnique({ 138 | where: { name: queryCategory as string }, 139 | }); 140 | if (!category) { 141 | return next(new ErrorResponse(resource404Error("category"), 404)); 142 | } 143 | categoryId = category.id; 144 | } 145 | 146 | const products = await prisma.product.findMany({ 147 | select, 148 | orderBy, 149 | skip, 150 | take, 151 | where: { 152 | AND: [ 153 | { 154 | AND: [ 155 | { 156 | price: price[0], 157 | }, 158 | { 159 | price: price[1], 160 | }, 161 | ], 162 | }, 163 | { 164 | AND: [ 165 | { 166 | stock: stock[0], 167 | }, 168 | { 169 | stock: stock[1], 170 | }, 171 | ], 172 | }, 173 | ], 174 | categoryId: { 175 | equals: categoryId, 176 | }, 177 | // tags: { 178 | // some: { 179 | // name: { in: ["skirt"] }, 180 | // }, 181 | // }, 182 | }, 183 | // include: { 184 | // tags: true, 185 | // }, 186 | 187 | // include: { category: true }, 188 | }); 189 | 190 | res.status(200).json({ 191 | success: true, 192 | count: products.length, 193 | data: products, 194 | }); 195 | }); 196 | 197 | /** 198 | * Get product count 199 | * @route GET /api/v1/products 200 | * @access Public 201 | */ 202 | export const getProductCount = asyncHandler(async (req, res, next) => { 203 | // requested queries 204 | const queryCategory = req.query.category; 205 | 206 | let categoryId: number | undefined; 207 | if (queryCategory) { 208 | const category = await prisma.category.findUnique({ 209 | where: { name: queryCategory as string }, 210 | }); 211 | if (!category) { 212 | return next(new ErrorResponse(resource404Error("category"), 404)); 213 | } 214 | categoryId = category.id; 215 | } 216 | 217 | const products = await prisma.product.findMany({ 218 | where: { 219 | categoryId: { 220 | equals: categoryId, 221 | }, 222 | }, 223 | }); 224 | 225 | res.status(200).json({ 226 | success: true, 227 | count: products.length, 228 | }); 229 | }); 230 | 231 | /** 232 | * Search products 233 | * @route GET /api/v1/products/search 234 | * @access Public 235 | */ 236 | export const searchProducts = asyncHandler(async (req, res, next) => { 237 | const querySearch = req.query.q; 238 | 239 | let search: string | undefined; 240 | let searchObj: string | Prisma.StringFilter | undefined; 241 | 242 | if (querySearch) { 243 | search = (querySearch as string).replace(" ", "|"); 244 | searchObj = { search: search, mode: "insensitive" }; 245 | } 246 | 247 | const products = await prisma.product.findMany({ 248 | where: { 249 | name: searchObj, 250 | description: searchObj, 251 | detail: searchObj, 252 | }, 253 | }); 254 | 255 | res.status(200).json({ 256 | success: true, 257 | count: products.length, 258 | data: products, 259 | }); 260 | }); 261 | 262 | /** 263 | * Get specific products 264 | * @route GET /api/v1/products/:id 265 | * @access Public 266 | */ 267 | export const getProduct = asyncHandler(async (req, res, next) => { 268 | const id = parseInt(req.params.id); 269 | const queryInclude = req.query.include; 270 | let include: Object | undefined; 271 | 272 | if (queryInclude === "category") { 273 | include = { category: true }; 274 | } 275 | 276 | // return error if include is specified and 277 | // include value is not "category" 278 | if (queryInclude && queryInclude !== "category") { 279 | return next(new ErrorResponse(invalidQuery, 400)); 280 | } 281 | 282 | const product = await prisma.product.findUnique({ 283 | where: { id }, 284 | include, 285 | }); 286 | 287 | // throws error if no product with that id found 288 | if (!product) { 289 | return next(new ErrorResponse(resource404Error("product"), 404)); 290 | } 291 | 292 | res.status(200).json({ 293 | success: true, 294 | data: product, 295 | }); 296 | }); 297 | 298 | /** 299 | * Create new product 300 | * @route POST /api/v1/products 301 | * @access Private 302 | */ 303 | export const createProduct = asyncHandler(async (req, res, next) => { 304 | type RequiredFieldsType = { 305 | name: string | undefined; 306 | price: string | undefined; 307 | description: string | undefined; 308 | image1: string | undefined; 309 | image2: string | undefined; 310 | }; 311 | 312 | let { 313 | name, 314 | price, 315 | description, 316 | image1, 317 | image2, 318 | discountPercent, 319 | detail, 320 | categoryId, 321 | stock, 322 | } = req.body; 323 | 324 | const requiredFields: RequiredFieldsType = { 325 | name, 326 | price, 327 | description, 328 | image1, 329 | image2, 330 | }; 331 | 332 | // Throws error if required field is not specified 333 | const hasError = checkRequiredFields(requiredFields, next); 334 | if (hasError !== false) return hasError; 335 | 336 | // Throws error if price field is not number or negative number 337 | if (!parseFloat(price) || parseFloat(price) < 0) { 338 | return next(new ErrorResponse(invalidPriceError, 400)); 339 | } 340 | 341 | // Throws error if stock field is not integer 342 | if (stock) { 343 | if (stock && !isIntegerAndPositive(stock)) { 344 | return next(new ErrorResponse(invalidStockError, 400)); 345 | } 346 | stock = parseInt(stock); 347 | } 348 | 349 | // Throws error if categoryId is invalid 350 | if (categoryId) { 351 | const category = await prisma.category.findUnique({ 352 | where: { id: parseInt(categoryId) }, 353 | }); 354 | if (!category) { 355 | return next(new ErrorResponse(invalidCategoryError(categoryId), 400)); 356 | } 357 | categoryId = parseInt(categoryId); 358 | } 359 | 360 | // let id: any; 361 | // if (process.env.NODE_ENV === "testing") { 362 | // id = parseInt(req.body.id); 363 | // } 364 | 365 | const product = await prisma.product.create({ 366 | data: { 367 | // id, // only for testing 368 | name, 369 | price, 370 | discountPercent, 371 | description, 372 | detail, 373 | category: { 374 | connect: { id: categoryId }, 375 | }, 376 | image1, 377 | image2, 378 | stock, 379 | // tags: { 380 | // create: [{ name: "trendy" }], 381 | // }, 382 | // categories: { 383 | // create: [{ name: 'Magic' }, { name: 'Butterflies' }], 384 | // }, 385 | }, 386 | }); 387 | 388 | res.status(201).json({ 389 | success: true, 390 | data: product, 391 | }); 392 | }); 393 | 394 | /** 395 | * Update a product 396 | * @route PUT /api/v1/products/:id 397 | * @access Private 398 | */ 399 | export const updateProduct = asyncHandler(async (req, res, next) => { 400 | const id = parseInt(req.params.id); 401 | 402 | let { 403 | name, 404 | price, 405 | discountPercent, 406 | description, 407 | detail, 408 | categoryId, 409 | image1, 410 | image2, 411 | stock, 412 | } = req.body; 413 | 414 | // Throws error if price field is not number 415 | if (price) { 416 | if (!parseFloat(price) || parseFloat(price) < 0) { 417 | return next(new ErrorResponse(invalidPriceError, 400)); 418 | } 419 | price = parseFloat(price); 420 | } 421 | 422 | // Throws error if stock field is not integer 423 | if (stock) { 424 | if (!isIntegerAndPositive(stock)) { 425 | return next(new ErrorResponse(invalidStockError, 400)); 426 | } 427 | stock = parseInt(stock); 428 | } 429 | 430 | // Throws error if categoryId is invalid 431 | if (categoryId) { 432 | const category = await prisma.category.findUnique({ 433 | where: { id: parseInt(categoryId) }, 434 | }); 435 | if (!category) { 436 | return next(new ErrorResponse(invalidCategoryError(categoryId), 400)); 437 | } 438 | categoryId = parseInt(categoryId); 439 | } 440 | 441 | if (discountPercent) { 442 | discountPercent = parseFloat(discountPercent); 443 | } 444 | 445 | const existingProduct = await prisma.product.findUnique({ 446 | where: { id }, 447 | }); 448 | 449 | const product = await prisma.product.update({ 450 | where: { id }, 451 | data: { 452 | name: name ? name : existingProduct?.name, 453 | price: price ? price : existingProduct?.price, 454 | discountPercent: discountPercent 455 | ? discountPercent 456 | : existingProduct?.discountPercent, 457 | description: description ? description : existingProduct?.description, 458 | detail: detail ? detail : existingProduct?.detail, 459 | category: { 460 | connect: { 461 | id: categoryId ? categoryId : existingProduct?.categoryId, 462 | }, 463 | }, 464 | image1: image1 ? image1 : existingProduct?.image1, 465 | image2: image2 ? image2 : existingProduct?.image2, 466 | stock: stock ? stock : existingProduct?.stock, 467 | updatedAt: new Date().toISOString(), 468 | }, 469 | }); 470 | 471 | res.status(200).json({ 472 | success: true, 473 | data: product, 474 | }); 475 | }); 476 | 477 | /** 478 | * Delete a product 479 | * @route DELETE /api/v1/products/:id 480 | * @access Private 481 | */ 482 | export const deleteProduct = asyncHandler(async (req, res, next) => { 483 | const id = parseInt(req.params.id); 484 | 485 | await prisma.product.delete({ 486 | where: { id }, 487 | }); 488 | 489 | res.status(204).json({ 490 | success: true, 491 | data: [], 492 | }); 493 | }); 494 | 495 | /*========================= Errors =============================*/ 496 | const invalidPriceError = errorObj( 497 | 400, 498 | errorTypes.invalidArgument, 499 | "invalid price field", 500 | [ 501 | { 502 | code: "invalidPrice", 503 | message: `price field must only be valid number`, 504 | }, 505 | ] 506 | ); 507 | 508 | const invalidStockError = errorObj( 509 | 400, 510 | errorTypes.invalidArgument, 511 | "invalid stock field", 512 | [ 513 | { 514 | code: "invalidStock", 515 | message: `stock field must only be valid integer`, 516 | }, 517 | ] 518 | ); 519 | 520 | const invalidCategoryError = (categoryId: string) => 521 | errorObj(400, errorTypes.invalidArgument, "invalid category id", [ 522 | { 523 | code: "invalidCategory", 524 | message: `there is no category with id ${categoryId}`, 525 | }, 526 | ]); 527 | -------------------------------------------------------------------------------- /db/seed.ts: -------------------------------------------------------------------------------- 1 | import { customers, categories, products, admins } from "./data"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | let prisma = new PrismaClient({ 5 | log: ["query", "info", "warn", "error"], 6 | }); 7 | 8 | async function main() { 9 | for (let customer of customers) { 10 | await prisma.customer.create({ 11 | data: customer, 12 | }); 13 | } 14 | 15 | for (let category of categories) { 16 | await prisma.category.create({ 17 | data: category, 18 | }); 19 | } 20 | 21 | for (let product of products) { 22 | await prisma.product.create({ 23 | data: product, 24 | }); 25 | } 26 | 27 | for (let admin of admins) { 28 | await prisma.admin.create({ 29 | data: admin, 30 | }); 31 | } 32 | } 33 | 34 | main() 35 | .catch((e) => { 36 | console.error(e); 37 | process.exit(1); 38 | }) 39 | .finally(async () => { 40 | await prisma.$disconnect(); 41 | }); 42 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | 3 | services: 4 | db: 5 | image: postgres:14rc1-alpine 6 | restart: unless-stopped 7 | container_name: integration-tests-prisma 8 | ports: 9 | - "5437:5432" 10 | environment: 11 | POSTGRES_USER: prisma 12 | POSTGRES_PASSWORD: prisma 13 | POSTGRES_DB: tests 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | node-app: 4 | build: . 5 | env_file: .env 6 | ports: 7 | - "${PORT}:${PORT}" 8 | volumes: 9 | - .:/app 10 | # - /app/node_modules 11 | depends_on: 12 | - postgres 13 | 14 | postgres: 15 | image: postgres:14rc1-alpine 16 | env_file: .env 17 | restart: unless-stopped 18 | ports: 19 | - "5432:5432" 20 | environment: 21 | POSTGRES_USER: $POSTGRES_USER 22 | POSTGRES_PASSWORD: $POSTGRES_PASSWORD 23 | POSTGRES_DB: $POSTGRES_DB 24 | volumes: 25 | - postgres:/var/lib/postgresql/data 26 | 27 | volumes: 28 | postgres: 29 | -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohnate/RESTful-API/b1619d1c2fab3b72d3c6dd2ae47b9520b5d6a4c5/docs/favicon.png -------------------------------------------------------------------------------- /docs/haru-api.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "f69c0f88-ca2b-4c40-8730-2a3e5e6070af", 4 | "name": "Haru API", 5 | "description": "Welcome to the Haru-Fashion API, a RESTful API for Haru-Fashion e-commerce web application. \n\nThis API can be used to access Haru-Fashion API endpoints, which can get information on various products, categories in our database. Certain information and actions, for example, retrieving customers, creating products, can only be done when authenticated and authorized. \n\nThis API is developed with NodeJS, Express, TypeScript, Prisma and PostgreSQL.", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Categories", 11 | "item": [ 12 | { 13 | "name": "Get All Categories", 14 | "request": { 15 | "method": "GET", 16 | "header": [], 17 | "url": { 18 | "raw": "{{URL}}/api/v1/categories?select=name,description&order_by=createdAt.desc,name", 19 | "host": [ 20 | "{{URL}}" 21 | ], 22 | "path": [ 23 | "api", 24 | "v1", 25 | "categories" 26 | ], 27 | "query": [ 28 | { 29 | "key": "select", 30 | "value": "name,description" 31 | }, 32 | { 33 | "key": "order_by", 34 | "value": "createdAt.desc,name" 35 | } 36 | ] 37 | }, 38 | "description": "Fetch all categories from the database. Includes select and ordering." 39 | }, 40 | "response": [] 41 | }, 42 | { 43 | "name": "Get Specific Category", 44 | "request": { 45 | "method": "GET", 46 | "header": [], 47 | "url": { 48 | "raw": "{{URL}}/api/v1/categories/1", 49 | "host": [ 50 | "{{URL}}" 51 | ], 52 | "path": [ 53 | "api", 54 | "v1", 55 | "categories", 56 | "1" 57 | ] 58 | }, 59 | "description": "Fetch specific category by ID from the database. Includes select and ordering." 60 | }, 61 | "response": [] 62 | }, 63 | { 64 | "name": "Create Category", 65 | "request": { 66 | "method": "POST", 67 | "header": [], 68 | "body": { 69 | "mode": "raw", 70 | "raw": "{\n \"name\": \"sneakers\",\n \"description\": \"this is desc\",\n \"thumbnailImage\": \"http://dummyimage.com/720x400.png/deefdd/000eee\"\n}", 71 | "options": { 72 | "raw": { 73 | "language": "json" 74 | } 75 | } 76 | }, 77 | "url": { 78 | "raw": "{{URL}}/api/v1/categories/", 79 | "host": [ 80 | "{{URL}}" 81 | ], 82 | "path": [ 83 | "api", 84 | "v1", 85 | "categories", 86 | "" 87 | ] 88 | }, 89 | "description": "Add new category to the database. To perform this action, user must be authenticated and authorized." 90 | }, 91 | "response": [] 92 | }, 93 | { 94 | "name": "Delete Category", 95 | "request": { 96 | "method": "DELETE", 97 | "header": [], 98 | "url": { 99 | "raw": "{{URL}}/api/v1/categories/4", 100 | "host": [ 101 | "{{URL}}" 102 | ], 103 | "path": [ 104 | "api", 105 | "v1", 106 | "categories", 107 | "4" 108 | ] 109 | }, 110 | "description": "Remove a category by its ID from the database. To perform this action, user must be authenticated and authorized." 111 | }, 112 | "response": [] 113 | }, 114 | { 115 | "name": "Update Category", 116 | "request": { 117 | "method": "PUT", 118 | "header": [], 119 | "body": { 120 | "mode": "raw", 121 | "raw": "{\n \"name\": \"men\"\n}", 122 | "options": { 123 | "raw": { 124 | "language": "json" 125 | } 126 | } 127 | }, 128 | "url": { 129 | "raw": "{{URL}}/api/v1/categories/1", 130 | "host": [ 131 | "{{URL}}" 132 | ], 133 | "path": [ 134 | "api", 135 | "v1", 136 | "categories", 137 | "1" 138 | ] 139 | }, 140 | "description": "Update specific category by its ID from the database. To perform this action, user must be authenticated and authorized." 141 | }, 142 | "response": [] 143 | } 144 | ] 145 | }, 146 | { 147 | "name": "Products", 148 | "item": [ 149 | { 150 | "name": "Get All Products", 151 | "request": { 152 | "method": "GET", 153 | "header": [], 154 | "url": { 155 | "raw": "{{URL}}/api/v1/products?order_by=price.desc,name&skip=50&limit=10&price=gte:50&price=lt:100&stock=gt:50&stock=lt:70&select=name,price,stock", 156 | "host": [ 157 | "{{URL}}" 158 | ], 159 | "path": [ 160 | "api", 161 | "v1", 162 | "products" 163 | ], 164 | "query": [ 165 | { 166 | "key": "order_by", 167 | "value": "price.desc,name" 168 | }, 169 | { 170 | "key": "skip", 171 | "value": "50" 172 | }, 173 | { 174 | "key": "limit", 175 | "value": "10" 176 | }, 177 | { 178 | "key": "price", 179 | "value": "gte:50" 180 | }, 181 | { 182 | "key": "price", 183 | "value": "lt:100" 184 | }, 185 | { 186 | "key": "stock", 187 | "value": "gt:50" 188 | }, 189 | { 190 | "key": "stock", 191 | "value": "lt:70" 192 | }, 193 | { 194 | "key": "stock", 195 | "value": "equals:58", 196 | "disabled": true 197 | }, 198 | { 199 | "key": "price", 200 | "value": "equals:55", 201 | "disabled": true 202 | }, 203 | { 204 | "key": "select", 205 | "value": "name,price,stock" 206 | } 207 | ] 208 | }, 209 | "description": "Fetch all products from the database. Includes select and ordering." 210 | }, 211 | "response": [] 212 | }, 213 | { 214 | "name": "Search Products", 215 | "request": { 216 | "method": "GET", 217 | "header": [], 218 | "url": { 219 | "raw": "{{URL}}/api/v1/products/search?q=condimentum", 220 | "host": [ 221 | "{{URL}}" 222 | ], 223 | "path": [ 224 | "api", 225 | "v1", 226 | "products", 227 | "search" 228 | ], 229 | "query": [ 230 | { 231 | "key": "q", 232 | "value": "condimentum" 233 | } 234 | ] 235 | }, 236 | "description": "Perform full-text search for all products." 237 | }, 238 | "response": [] 239 | }, 240 | { 241 | "name": "Get Specific Product", 242 | "request": { 243 | "method": "GET", 244 | "header": [], 245 | "url": { 246 | "raw": "{{URL}}/api/v1/products/5?include=category", 247 | "host": [ 248 | "{{URL}}" 249 | ], 250 | "path": [ 251 | "api", 252 | "v1", 253 | "products", 254 | "5" 255 | ], 256 | "query": [ 257 | { 258 | "key": "include", 259 | "value": "category" 260 | } 261 | ] 262 | }, 263 | "description": "Fetch specific product by ID from the database. Includes select and ordering." 264 | }, 265 | "response": [] 266 | }, 267 | { 268 | "name": "Create New Product", 269 | "request": { 270 | "method": "POST", 271 | "header": [], 272 | "body": { 273 | "mode": "raw", 274 | "raw": "{\n \"name\": \"Wallie\",\n \"price\": \"1500\",\n \"description\": \"this is just a description\",\n \"image1\": \"image1.png\",\n \"image2\": \"image2.png\",\n \"categoryId\": \"1\"\n}", 275 | "options": { 276 | "raw": { 277 | "language": "json" 278 | } 279 | } 280 | }, 281 | "url": { 282 | "raw": "{{URL}}/api/v1/products", 283 | "host": [ 284 | "{{URL}}" 285 | ], 286 | "path": [ 287 | "api", 288 | "v1", 289 | "products" 290 | ] 291 | }, 292 | "description": "Add new product to the database. To perform this action, user must be authenticated and authorized." 293 | }, 294 | "response": [] 295 | }, 296 | { 297 | "name": "Update Product", 298 | "request": { 299 | "method": "PUT", 300 | "header": [], 301 | "body": { 302 | "mode": "raw", 303 | "raw": "{\n \"name\": \"updated category\",\n \"price\": \"100\",\n \"discountPercent\": \"5\",\n \"description\": \"this is updated description\",\n \"detail\": \"this is updated detail\",\n \"categoryId\": \"2\",\n \"image1\": \"image1.png\",\n \"image2\": \"image2.png\",\n \"stock\": \"20\"\n }", 304 | "options": { 305 | "raw": { 306 | "language": "json" 307 | } 308 | } 309 | }, 310 | "url": { 311 | "raw": "{{URL}}/api/v1/products/101", 312 | "host": [ 313 | "{{URL}}" 314 | ], 315 | "path": [ 316 | "api", 317 | "v1", 318 | "products", 319 | "101" 320 | ] 321 | }, 322 | "description": "Update specific product by its ID from the database. To perform this action, user must be authenticated and authorized." 323 | }, 324 | "response": [] 325 | }, 326 | { 327 | "name": "Delete Specific Product", 328 | "request": { 329 | "method": "DELETE", 330 | "header": [], 331 | "url": { 332 | "raw": "{{URL}}/api/v1/products/102", 333 | "host": [ 334 | "{{URL}}" 335 | ], 336 | "path": [ 337 | "api", 338 | "v1", 339 | "products", 340 | "102" 341 | ] 342 | }, 343 | "description": "Remove a product by its ID from the database. To perform this action, user must be authenticated and authorized." 344 | }, 345 | "response": [] 346 | } 347 | ] 348 | }, 349 | { 350 | "name": "Customers", 351 | "item": [ 352 | { 353 | "name": "Get All Customers", 354 | "request": { 355 | "method": "GET", 356 | "header": [], 357 | "url": { 358 | "raw": "{{URL}}/api/v1/customers", 359 | "host": [ 360 | "{{URL}}" 361 | ], 362 | "path": [ 363 | "api", 364 | "v1", 365 | "customers" 366 | ] 367 | }, 368 | "description": "Fetch all customers from the database. To perform this action, user must be authenticated and authorized." 369 | }, 370 | "response": [] 371 | }, 372 | { 373 | "name": "Get Specific Customer", 374 | "request": { 375 | "method": "GET", 376 | "header": [], 377 | "url": { 378 | "raw": "{{URL}}/api/v1/customers/3", 379 | "host": [ 380 | "{{URL}}" 381 | ], 382 | "path": [ 383 | "api", 384 | "v1", 385 | "customers", 386 | "3" 387 | ] 388 | }, 389 | "description": "Fetch specific customer by their ID from the database. To perform this action, user must be authenticated and authorized." 390 | }, 391 | "response": [] 392 | }, 393 | { 394 | "name": "Delete Customer", 395 | "request": { 396 | "method": "DELETE", 397 | "header": [], 398 | "url": { 399 | "raw": "{{URL}}/api/v1/customers/999", 400 | "host": [ 401 | "{{URL}}" 402 | ], 403 | "path": [ 404 | "api", 405 | "v1", 406 | "customers", 407 | "999" 408 | ] 409 | }, 410 | "description": "Delete a customer by their ID from the database. To perform this action, user must be authenticated and authorized." 411 | }, 412 | "response": [] 413 | } 414 | ] 415 | }, 416 | { 417 | "name": "Auth", 418 | "item": [ 419 | { 420 | "name": "Register Customer", 421 | "request": { 422 | "method": "POST", 423 | "header": [], 424 | "body": { 425 | "mode": "raw", 426 | "raw": "{\n \"email\": \"demo@gmail.com\",\n \"fullname\": \"newuser\",\n \"password\": \"demopassword\",\n \"shippingAddress\": \"yangon\",\n \"phone\": \"09283928\"\n }", 427 | "options": { 428 | "raw": { 429 | "language": "json" 430 | } 431 | } 432 | }, 433 | "url": { 434 | "raw": "{{URL}}/api/v1/auth/register", 435 | "host": [ 436 | "{{URL}}" 437 | ], 438 | "path": [ 439 | "api", 440 | "v1", 441 | "auth", 442 | "register" 443 | ] 444 | }, 445 | "description": "Register a new customer by passing required fields in the body." 446 | }, 447 | "response": [] 448 | }, 449 | { 450 | "name": "Login Customer", 451 | "event": [ 452 | { 453 | "listen": "test", 454 | "script": { 455 | "exec": [ 456 | "pm.environment.set('TOKEN', pm.response.json().token)" 457 | ], 458 | "type": "text/javascript" 459 | } 460 | } 461 | ], 462 | "request": { 463 | "method": "POST", 464 | "header": [], 465 | "body": { 466 | "mode": "raw", 467 | "raw": "{\n \"email\": \"demo@gmail.com\",\n \"password\": \"demopassword\"\n }", 468 | "options": { 469 | "raw": { 470 | "language": "json" 471 | } 472 | } 473 | }, 474 | "url": { 475 | "raw": "{{URL}}/api/v1/auth/login", 476 | "host": [ 477 | "{{URL}}" 478 | ], 479 | "path": [ 480 | "api", 481 | "v1", 482 | "auth", 483 | "login" 484 | ] 485 | }, 486 | "description": "Login as a customer when the customer is already registered. " 487 | }, 488 | "response": [] 489 | }, 490 | { 491 | "name": "Get Me", 492 | "protocolProfileBehavior": { 493 | "disableBodyPruning": true 494 | }, 495 | "request": { 496 | "auth": { 497 | "type": "bearer", 498 | "bearer": [ 499 | { 500 | "key": "token", 501 | "value": "{{TOKEN}}", 502 | "type": "string" 503 | } 504 | ] 505 | }, 506 | "method": "GET", 507 | "header": [], 508 | "body": { 509 | "mode": "raw", 510 | "raw": "" 511 | }, 512 | "url": { 513 | "raw": "{{URL}}/api/v1/auth/me", 514 | "host": [ 515 | "{{URL}}" 516 | ], 517 | "path": [ 518 | "api", 519 | "v1", 520 | "auth", 521 | "me" 522 | ] 523 | }, 524 | "description": "Retrieve current logged-in customer's details." 525 | }, 526 | "response": [] 527 | }, 528 | { 529 | "name": "Update Customer Details (self)", 530 | "request": { 531 | "auth": { 532 | "type": "bearer", 533 | "bearer": [ 534 | { 535 | "key": "token", 536 | "value": "{{TOKEN}}", 537 | "type": "string" 538 | } 539 | ] 540 | }, 541 | "method": "PUT", 542 | "header": [], 543 | "body": { 544 | "mode": "raw", 545 | "raw": "{\n \"fullname\": \"Mrs.Demo\",\n \"phone\": \"571661\"\n}", 546 | "options": { 547 | "raw": { 548 | "language": "json" 549 | } 550 | } 551 | }, 552 | "url": { 553 | "raw": "{{URL}}/api/v1/auth/update-details", 554 | "host": [ 555 | "{{URL}}" 556 | ], 557 | "path": [ 558 | "api", 559 | "v1", 560 | "auth", 561 | "update-details" 562 | ] 563 | }, 564 | "description": "Update current logged-in customer's details." 565 | }, 566 | "response": [] 567 | }, 568 | { 569 | "name": "Update Customer Password (self)", 570 | "request": { 571 | "auth": { 572 | "type": "bearer", 573 | "bearer": [ 574 | { 575 | "key": "token", 576 | "value": "{{TOKEN}}", 577 | "type": "string" 578 | } 579 | ] 580 | }, 581 | "method": "PUT", 582 | "header": [], 583 | "body": { 584 | "mode": "raw", 585 | "raw": "{\n \"currentPassword\": \"demopassword2\",\n \"newPassword\": \"demopassword\"\n}", 586 | "options": { 587 | "raw": { 588 | "language": "json" 589 | } 590 | } 591 | }, 592 | "url": { 593 | "raw": "{{URL}}/api/v1/auth/change-password", 594 | "host": [ 595 | "{{URL}}" 596 | ], 597 | "path": [ 598 | "api", 599 | "v1", 600 | "auth", 601 | "change-password" 602 | ] 603 | }, 604 | "description": "Update current logged-in customer's password. Customer must type current password correctly in order to perform this action." 605 | }, 606 | "response": [] 607 | }, 608 | { 609 | "name": "Forgot Password", 610 | "request": { 611 | "method": "POST", 612 | "header": [], 613 | "body": { 614 | "mode": "raw", 615 | "raw": "{\n \"email\": \"demo@gmail.com\"\n}", 616 | "options": { 617 | "raw": { 618 | "language": "json" 619 | } 620 | } 621 | }, 622 | "url": { 623 | "raw": "{{URL}}/api/v1/auth/forgot-password", 624 | "host": [ 625 | "{{URL}}" 626 | ], 627 | "path": [ 628 | "api", 629 | "v1", 630 | "auth", 631 | "forgot-password" 632 | ] 633 | }, 634 | "description": "Forgot password can be reset by providing email in the body. Reset password token is sent via email." 635 | }, 636 | "response": [] 637 | }, 638 | { 639 | "name": "Reset Password", 640 | "request": { 641 | "method": "PUT", 642 | "header": [], 643 | "body": { 644 | "mode": "raw", 645 | "raw": "{\n \"password\": \"newpassword\"\n}", 646 | "options": { 647 | "raw": { 648 | "language": "json" 649 | } 650 | } 651 | }, 652 | "url": { 653 | "raw": "{{URL}}/api/v1/auth/reset-password/8935f53cee400111c328f6c6ba57c134f330c4ca", 654 | "host": [ 655 | "{{URL}}" 656 | ], 657 | "path": [ 658 | "api", 659 | "v1", 660 | "auth", 661 | "reset-password", 662 | "8935f53cee400111c328f6c6ba57c134f330c4ca" 663 | ] 664 | }, 665 | "description": "To reset password, this route can be requested (which is sent via email)" 666 | }, 667 | "response": [] 668 | } 669 | ] 670 | }, 671 | { 672 | "name": "Admins", 673 | "item": [ 674 | { 675 | "name": "Create Admin", 676 | "request": { 677 | "auth": { 678 | "type": "bearer", 679 | "bearer": [ 680 | { 681 | "key": "token", 682 | "value": "{{TOKEN}}", 683 | "type": "string" 684 | } 685 | ] 686 | }, 687 | "method": "POST", 688 | "header": [], 689 | "body": { 690 | "mode": "raw", 691 | "raw": "{\n \"username\": \"admin2\",\n \"email\": \"admin2@gmail.com\",\n \"password\": \"admin2\",\n \"role\": \"ADMIN\"\n}", 692 | "options": { 693 | "raw": { 694 | "language": "json" 695 | } 696 | } 697 | }, 698 | "url": { 699 | "raw": "{{URL}}/api/v1/admins", 700 | "host": [ 701 | "{{URL}}" 702 | ], 703 | "path": [ 704 | "api", 705 | "v1", 706 | "admins" 707 | ] 708 | }, 709 | "description": "Add a new admin to the database. To perform this action, user must be authenticated and authorized as a superadmin." 710 | }, 711 | "response": [] 712 | }, 713 | { 714 | "name": "Admin Login", 715 | "event": [ 716 | { 717 | "listen": "test", 718 | "script": { 719 | "exec": [ 720 | "pm.environment.set('TOKEN', pm.response.json().token)" 721 | ], 722 | "type": "text/javascript" 723 | } 724 | } 725 | ], 726 | "request": { 727 | "method": "POST", 728 | "header": [], 729 | "body": { 730 | "mode": "raw", 731 | "raw": "{\n \"email\": \"dummyadmin@gmail.com\",\n \"password\": \"verysecurepassword\"\n}", 732 | "options": { 733 | "raw": { 734 | "language": "json" 735 | } 736 | } 737 | }, 738 | "url": { 739 | "raw": "{{URL}}/api/v1/admins/login", 740 | "host": [ 741 | "{{URL}}" 742 | ], 743 | "path": [ 744 | "api", 745 | "v1", 746 | "admins", 747 | "login" 748 | ] 749 | }, 750 | "description": "Login as an admin." 751 | }, 752 | "response": [] 753 | }, 754 | { 755 | "name": "Get Current Admin", 756 | "request": { 757 | "auth": { 758 | "type": "bearer", 759 | "bearer": [ 760 | { 761 | "key": "token", 762 | "value": "{{TOKEN}}", 763 | "type": "string" 764 | } 765 | ] 766 | }, 767 | "method": "GET", 768 | "header": [], 769 | "url": { 770 | "raw": "{{URL}}/api/v1/admins/me", 771 | "host": [ 772 | "{{URL}}" 773 | ], 774 | "path": [ 775 | "api", 776 | "v1", 777 | "admins", 778 | "me" 779 | ] 780 | }, 781 | "description": "Retrieve current logged-in admin's details." 782 | }, 783 | "response": [] 784 | }, 785 | { 786 | "name": "Change Admin Password", 787 | "request": { 788 | "auth": { 789 | "type": "bearer", 790 | "bearer": [ 791 | { 792 | "key": "token", 793 | "value": "{{TOKEN}}", 794 | "type": "string" 795 | } 796 | ] 797 | }, 798 | "method": "POST", 799 | "header": [], 800 | "body": { 801 | "mode": "raw", 802 | "raw": "{\n \"currentPassword\": \"verysecurepassword\",\n \"newPassword\": \"nextsecurepassword\"\n}", 803 | "options": { 804 | "raw": { 805 | "language": "json" 806 | } 807 | } 808 | }, 809 | "url": { 810 | "raw": "{{URL}}/api/v1/admins/change-password", 811 | "host": [ 812 | "{{URL}}" 813 | ], 814 | "path": [ 815 | "api", 816 | "v1", 817 | "admins", 818 | "change-password" 819 | ] 820 | }, 821 | "description": "Update current logged-in admin's password. User must type current password correctly in order to perform this action.\n" 822 | }, 823 | "response": [] 824 | }, 825 | { 826 | "name": "Update Admin (self)", 827 | "request": { 828 | "auth": { 829 | "type": "bearer", 830 | "bearer": [ 831 | { 832 | "key": "token", 833 | "value": "{{TOKEN}}", 834 | "type": "string" 835 | } 836 | ] 837 | }, 838 | "method": "PUT", 839 | "header": [], 840 | "body": { 841 | "mode": "raw", 842 | "raw": "{\n \"username\": \"updated admin\",\n \"email\": \"updatedemail@gmail.com\"\n}", 843 | "options": { 844 | "raw": { 845 | "language": "json" 846 | } 847 | } 848 | }, 849 | "url": { 850 | "raw": "{{URL}}/api/v1/admins/", 851 | "host": [ 852 | "{{URL}}" 853 | ], 854 | "path": [ 855 | "api", 856 | "v1", 857 | "admins", 858 | "" 859 | ] 860 | }, 861 | "description": "Update current logged-in admin's details." 862 | }, 863 | "response": [] 864 | }, 865 | { 866 | "name": "Get All Admins (by superadmin)", 867 | "request": { 868 | "auth": { 869 | "type": "bearer", 870 | "bearer": [ 871 | { 872 | "key": "token", 873 | "value": "{{TOKEN}}", 874 | "type": "string" 875 | } 876 | ] 877 | }, 878 | "method": "GET", 879 | "header": [], 880 | "url": { 881 | "raw": "{{URL}}/api/v1/admins/", 882 | "host": [ 883 | "{{URL}}" 884 | ], 885 | "path": [ 886 | "api", 887 | "v1", 888 | "admins", 889 | "" 890 | ] 891 | }, 892 | "description": "Retrieve all admins from the database. This action can only be performed by superadmin role.\n" 893 | }, 894 | "response": [] 895 | }, 896 | { 897 | "name": "Get Specific Admin (by superadmin)", 898 | "request": { 899 | "auth": { 900 | "type": "bearer", 901 | "bearer": [ 902 | { 903 | "key": "token", 904 | "value": "{{TOKEN}}", 905 | "type": "string" 906 | } 907 | ] 908 | }, 909 | "method": "GET", 910 | "header": [], 911 | "url": { 912 | "raw": "{{URL}}/api/v1/admins/1", 913 | "host": [ 914 | "{{URL}}" 915 | ], 916 | "path": [ 917 | "api", 918 | "v1", 919 | "admins", 920 | "1" 921 | ] 922 | }, 923 | "description": "Retrieve specific admin by their ID from the database. This action can only be performed by superadmin role.\n" 924 | }, 925 | "response": [] 926 | }, 927 | { 928 | "name": "Update Admin (by superadmin)", 929 | "request": { 930 | "auth": { 931 | "type": "bearer", 932 | "bearer": [ 933 | { 934 | "key": "token", 935 | "value": "{{TOKEN}}", 936 | "type": "string" 937 | } 938 | ] 939 | }, 940 | "method": "PUT", 941 | "header": [], 942 | "body": { 943 | "mode": "raw", 944 | "raw": "{\n \"username\": \"admin11\",\n \"active\": true,\n \"password\": \"admin11\"\n}", 945 | "options": { 946 | "raw": { 947 | "language": "json" 948 | } 949 | } 950 | }, 951 | "url": { 952 | "raw": "{{URL}}/api/v1/admins/2", 953 | "host": [ 954 | "{{URL}}" 955 | ], 956 | "path": [ 957 | "api", 958 | "v1", 959 | "admins", 960 | "2" 961 | ] 962 | }, 963 | "description": "Update specific admin by their ID. This action can only be performed by superadmin role.\n" 964 | }, 965 | "response": [] 966 | }, 967 | { 968 | "name": "Seed Database", 969 | "request": { 970 | "method": "POST", 971 | "header": [], 972 | "body": { 973 | "mode": "raw", 974 | "raw": "{\n \"password\": \"secret_seeding_password\"\n}", 975 | "options": { 976 | "raw": { 977 | "language": "json" 978 | } 979 | } 980 | }, 981 | "url": { 982 | "raw": "{{URL}}/api/v1/admins/seed", 983 | "host": [ 984 | "{{URL}}" 985 | ], 986 | "path": [ 987 | "api", 988 | "v1", 989 | "admins", 990 | "seed" 991 | ] 992 | }, 993 | "description": "Seed data to the database when deployed. To perform this action, seeding password must be correct." 994 | }, 995 | "response": [] 996 | } 997 | ] 998 | } 999 | ], 1000 | "event": [ 1001 | { 1002 | "listen": "prerequest", 1003 | "script": { 1004 | "type": "text/javascript", 1005 | "exec": [ 1006 | "" 1007 | ] 1008 | } 1009 | }, 1010 | { 1011 | "listen": "test", 1012 | "script": { 1013 | "type": "text/javascript", 1014 | "exec": [ 1015 | "" 1016 | ] 1017 | } 1018 | } 1019 | ] 1020 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | setupFilesAfterEnv: ["jest-sorted", "jest-extended"], 6 | // testTimeout: 20000, 7 | // setupFilesAfterEnv: ["/prisma/singleton.ts"], 8 | }; 9 | -------------------------------------------------------------------------------- /middlewares/asyncHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { ExtendedRequest } from "../utils/extendedRequest"; 3 | 4 | type Function = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction 8 | ) => Promise; 9 | 10 | const asyncHandler = 11 | (fn: Function) => 12 | (req: Request | ExtendedRequest, res: Response, next: NextFunction) => 13 | Promise.resolve(fn(req, res, next)).catch(next); 14 | 15 | export default asyncHandler; 16 | -------------------------------------------------------------------------------- /middlewares/authHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from "express"; 2 | import jwt, { JwtPayload } from "jsonwebtoken"; 3 | import prisma from "../prisma/client"; 4 | import errorObj, { 5 | errorTypes, 6 | authRequiredError, 7 | unauthorizedError, 8 | } from "../utils/errorObject"; 9 | import ErrorResponse from "../utils/errorResponse"; 10 | import { ExtendedRequest } from "../utils/extendedRequest"; 11 | import asyncHandler from "./asyncHandler"; 12 | 13 | /** 14 | * Middleware for protected routes 15 | * @description used in routes before auth required controllers 16 | * @return auth error | next() 17 | */ 18 | export const protect = asyncHandler(async (req: ExtendedRequest, res, next) => { 19 | let token: string = ""; 20 | if ( 21 | req.headers.authorization && 22 | req.headers.authorization.startsWith("Bearer") 23 | ) { 24 | token = req.headers.authorization.split(" ")[1]; 25 | } 26 | 27 | if (!token) { 28 | return next(new ErrorResponse(authRequiredError, 401)); 29 | } 30 | 31 | // Verify token 32 | try { 33 | const decoded = jwt.verify(token, process.env.JWT_SECRET as string); 34 | req.user = await prisma.customer.findUnique({ 35 | where: { id: parseInt((decoded as JwtPayload).id) }, 36 | }); 37 | next(); 38 | } catch (err) { 39 | console.log(err); 40 | return next(new ErrorResponse(authRequiredError, 401)); 41 | } 42 | }); 43 | 44 | /** 45 | * Middleware for admin only routes 46 | * @description used in routes before auth required controllers 47 | * @return auth error | next() 48 | */ 49 | export const adminOnly = asyncHandler( 50 | async (req: ExtendedRequest, res, next) => { 51 | let token: string = ""; 52 | if ( 53 | req.headers.authorization && 54 | req.headers.authorization.startsWith("Bearer") 55 | ) { 56 | token = req.headers.authorization.split(" ")[1]; 57 | } 58 | 59 | if (!token) { 60 | return next(new ErrorResponse(authRequiredError, 401)); 61 | } 62 | 63 | // Verify token 64 | try { 65 | const decoded = jwt.verify(token, process.env.JWT_SECRET as string); 66 | req.admin = await prisma.admin.findUnique({ 67 | where: { id: parseInt((decoded as JwtPayload).id) }, 68 | }); 69 | next(); 70 | } catch (err) { 71 | console.log(err); 72 | return next(new ErrorResponse(authRequiredError, 401)); 73 | } 74 | } 75 | ); 76 | 77 | /** 78 | * Authorized Middleware 79 | * @param roles - "SUPERADMIN", "ADMIN", "MODERATOR" 80 | * @returns authorize error | next() 81 | */ 82 | export const authorize = 83 | (...roles: string[]) => 84 | (req: ExtendedRequest, res: Response, next: NextFunction) => { 85 | if (!roles.includes(req!.admin!.role)) { 86 | return next(new ErrorResponse(unauthorizedError, 403)); 87 | } 88 | next(); 89 | }; 90 | -------------------------------------------------------------------------------- /middlewares/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from "express"; 2 | import errorObj, { defaultError, errorTypes } from "../utils/errorObject"; 3 | import ErrorResponse from "../utils/errorResponse"; 4 | 5 | const errorHandler: ErrorRequestHandler = (err, req, res, next) => { 6 | let error = { ...err }; 7 | 8 | if (process.env.NODE_ENV === "development") { 9 | console.log("Error Handler get called"); 10 | console.error(err); 11 | } 12 | 13 | // Some error 14 | // if(err.name === "someError") { 15 | // const message = `Resource not found with id of ${err.value}`; 16 | // error = new ErrorResponse(message, 404) 17 | // } 18 | 19 | // Unique constraint failed 20 | if (err.code === "P2002") { 21 | const uniqueConstraintError = errorObj( 22 | 400, 23 | errorTypes.alreadyExists, 24 | `${err.meta.target[0]} already exists` 25 | ); 26 | error = new ErrorResponse(uniqueConstraintError, 400); 27 | } 28 | 29 | // Record to do something not found 30 | if (err.code === "P2025") { 31 | const errNotFoundObj = errorObj( 32 | 404, 33 | errorTypes.notFound, 34 | (err.meta.cause as string).toLowerCase() 35 | ); 36 | error = new ErrorResponse(errNotFoundObj, 404); 37 | } 38 | 39 | res.status(error.statusCode || 500).json({ 40 | success: false, 41 | error: error.errObj || defaultError, 42 | }); 43 | }; 44 | 45 | export default errorHandler; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "haru-api", 3 | "version": "1.0.0", 4 | "description": "A RESTful API for Haru-Fashion e-commerce web application.", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node dist/server.js", 8 | "devStart": "nodemon server.ts", 9 | "dev": "docker-compose up", 10 | "dev:down": "docker-compose down", 11 | "dev:build": "docker-compose up --build", 12 | "build": "tsc -p .", 13 | "migrate:test": "dotenv -e .env.test -- npx prisma migrate dev --name postgres-init", 14 | "seed:test": "dotenv -e .env.test -- npx prisma db seed", 15 | "generate:test": "dotenv -e .env.test -- npx prisma generate", 16 | "test:up": "docker-compose -f docker-compose.test.yml up -d", 17 | "test:down": "docker-compose -f docker-compose.test.yml down", 18 | "test": "npm run test:up && sleep 5; npm run migrate:test && npm run seed:test && dotenv -e .env.test jest -- --watchAll", 19 | "test:circle": "jest -i" 20 | }, 21 | "prisma": { 22 | "seed": "ts-node db/seed.ts" 23 | }, 24 | "keywords": [ 25 | "nodejs", 26 | "express", 27 | "prisma", 28 | "postgresql", 29 | "api", 30 | "rest-api" 31 | ], 32 | "author": "", 33 | "license": "ISC", 34 | "dependencies": { 35 | "@prisma/client": "^3.1.1", 36 | "@sendgrid/mail": "^7.6.1", 37 | "bcryptjs": "^2.4.3", 38 | "cors": "^2.8.5", 39 | "dotenv": "^10.0.0", 40 | "express": "^4.17.1", 41 | "express-rate-limit": "^5.4.1", 42 | "helmet": "^4.6.0", 43 | "jsonwebtoken": "^8.5.1", 44 | "nodemailer": "^6.6.5" 45 | }, 46 | "devDependencies": { 47 | "@types/bcryptjs": "^2.4.2", 48 | "@types/cors": "^2.8.12", 49 | "@types/express": "^4.17.13", 50 | "@types/express-rate-limit": "^5.1.3", 51 | "@types/jest": "^27.0.2", 52 | "@types/jsonwebtoken": "^8.5.5", 53 | "@types/morgan": "^1.9.3", 54 | "@types/node": "^16.10.1", 55 | "@types/nodemailer": "^6.4.4", 56 | "@types/supertest": "^2.0.11", 57 | "jest": "^27.2.2", 58 | "jest-extended": "^0.11.5", 59 | "jest-mock-extended": "^2.0.4", 60 | "jest-sorted": "^1.0.12", 61 | "morgan": "^1.10.0", 62 | "nodemon": "^2.0.13", 63 | "prisma": "^3.1.1", 64 | "supertest": "^6.1.6", 65 | "ts-jest": "^27.0.5", 66 | "ts-node": "^10.2.1", 67 | "typescript": "^4.4.3" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | // let prisma = new PrismaClient({ 6 | // log: ["query", "info", "warn", "error"], 7 | // }); 8 | 9 | export default prisma; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20210927132352_postgres_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('SUPERADMIN', 'ADMIN', 'MODERATOR'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "admins" ( 6 | "id" SERIAL NOT NULL, 7 | "username" VARCHAR(50) NOT NULL, 8 | "email" VARCHAR(50) NOT NULL, 9 | "password" VARCHAR(255) NOT NULL, 10 | "role" "Role" NOT NULL DEFAULT E'ADMIN', 11 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updated_at" TIMESTAMP(3), 13 | 14 | CONSTRAINT "admins_pkey" PRIMARY KEY ("id") 15 | ); 16 | 17 | -- CreateTable 18 | CREATE TABLE "categories" ( 19 | "id" SERIAL NOT NULL, 20 | "name" VARCHAR(50) NOT NULL, 21 | "description" VARCHAR(255), 22 | "thumbnail_image" VARCHAR(100), 23 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | "updated_at" TIMESTAMP(3), 25 | 26 | CONSTRAINT "categories_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "products" ( 31 | "id" SERIAL NOT NULL, 32 | "name" VARCHAR(50) NOT NULL, 33 | "price" DECIMAL(7,2) NOT NULL, 34 | "discount_percent" INTEGER DEFAULT 0, 35 | "description" VARCHAR(255) NOT NULL, 36 | "detail" TEXT, 37 | "category_id" INTEGER, 38 | "image_1" VARCHAR(100) NOT NULL, 39 | "image_2" VARCHAR(100) NOT NULL, 40 | "stock" INTEGER DEFAULT 0, 41 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 42 | "updated_at" TIMESTAMP(3), 43 | 44 | CONSTRAINT "products_pkey" PRIMARY KEY ("id") 45 | ); 46 | 47 | -- CreateTable 48 | CREATE TABLE "customers" ( 49 | "id" SERIAL NOT NULL, 50 | "fullname" VARCHAR(50) NOT NULL, 51 | "email" VARCHAR(50) NOT NULL, 52 | "password" VARCHAR(255) NOT NULL, 53 | "default_shipping_address" TEXT NOT NULL, 54 | "phone" VARCHAR(20), 55 | "reset_password_token" VARCHAR(255), 56 | "reset_password_expire" TIMESTAMP(3), 57 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 58 | "updated_at" TIMESTAMP(3), 59 | 60 | CONSTRAINT "customers_pkey" PRIMARY KEY ("id") 61 | ); 62 | 63 | -- CreateTable 64 | CREATE TABLE "orders" ( 65 | "order_number" SERIAL NOT NULL, 66 | "customer_id" INTEGER NOT NULL, 67 | "address" TEXT NOT NULL, 68 | "township" VARCHAR(20) NOT NULL, 69 | "city" VARCHAR(20) NOT NULL, 70 | "state" VARCHAR(20) NOT NULL, 71 | "zip_code" VARCHAR(20) NOT NULL, 72 | "order_date" DATE NOT NULL, 73 | "delivery_date" DATE NOT NULL, 74 | 75 | CONSTRAINT "orders_pkey" PRIMARY KEY ("order_number") 76 | ); 77 | 78 | -- CreateTable 79 | CREATE TABLE "order_details" ( 80 | "orderNumber" INTEGER NOT NULL, 81 | "productId" INTEGER NOT NULL, 82 | "quantity" INTEGER NOT NULL DEFAULT 1, 83 | 84 | CONSTRAINT "order_details_pkey" PRIMARY KEY ("orderNumber","productId") 85 | ); 86 | 87 | -- CreateIndex 88 | CREATE UNIQUE INDEX "admins_email_key" ON "admins"("email"); 89 | 90 | -- CreateIndex 91 | CREATE UNIQUE INDEX "customers_email_key" ON "customers"("email"); 92 | 93 | -- CreateIndex 94 | CREATE UNIQUE INDEX "customers_reset_password_token_key" ON "customers"("reset_password_token"); 95 | 96 | -- AddForeignKey 97 | ALTER TABLE "products" ADD CONSTRAINT "products_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE NO ACTION ON UPDATE CASCADE; 98 | 99 | -- AddForeignKey 100 | ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 101 | 102 | -- AddForeignKey 103 | ALTER TABLE "order_details" ADD CONSTRAINT "order_details_orderNumber_fkey" FOREIGN KEY ("orderNumber") REFERENCES "orders"("order_number") ON DELETE CASCADE ON UPDATE CASCADE; 104 | 105 | -- AddForeignKey 106 | ALTER TABLE "order_details" ADD CONSTRAINT "order_details_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; 107 | -------------------------------------------------------------------------------- /prisma/migrations/20210928175554_make_category_name_unique/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[name]` on the table `categories` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name"); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20211006091835_added_admin_role/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "admins" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20211008155526_reset_pwd_expire/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `reset_password_expire` column on the `customers` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "customers" DROP COLUMN "reset_password_expire", 9 | ADD COLUMN "reset_password_expire" INTEGER; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20211008161931_reset_pwd_expire/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "customers" ALTER COLUMN "reset_password_expire" SET DATA TYPE BIGINT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220303144904_add_tags_for_products/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "tags" ( 3 | "id" SERIAL NOT NULL, 4 | "name" VARCHAR(50) NOT NULL, 5 | 6 | CONSTRAINT "tags_pkey" PRIMARY KEY ("id") 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "_ProductToTag" ( 11 | "A" INTEGER NOT NULL, 12 | "B" INTEGER NOT NULL 13 | ); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "_ProductToTag_AB_unique" ON "_ProductToTag"("A", "B"); 17 | 18 | -- CreateIndex 19 | CREATE INDEX "_ProductToTag_B_index" ON "_ProductToTag"("B"); 20 | 21 | -- AddForeignKey 22 | ALTER TABLE "_ProductToTag" ADD FOREIGN KEY ("A") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; 23 | 24 | -- AddForeignKey 25 | ALTER TABLE "_ProductToTag" ADD FOREIGN KEY ("B") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; 26 | -------------------------------------------------------------------------------- /prisma/migrations/20220305160533_updated_order_fields/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `total_price` to the `orders` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "DeliveryType" AS ENUM ('STORE_PICKUP', 'YANGON', 'OTHERS'); 9 | 10 | -- CreateEnum 11 | CREATE TYPE "PaymentType" AS ENUM ('CASH_ON_DELIVERY', 'BANK_TRANSFER'); 12 | 13 | -- AlterTable 14 | ALTER TABLE "orders" ADD COLUMN "delivery_type" "DeliveryType" NOT NULL DEFAULT E'STORE_PICKUP', 15 | ADD COLUMN "payment_type" "PaymentType" NOT NULL DEFAULT E'CASH_ON_DELIVERY', 16 | ADD COLUMN "total_price" DOUBLE PRECISION NOT NULL, 17 | ALTER COLUMN "township" DROP NOT NULL, 18 | ALTER COLUMN "city" DROP NOT NULL, 19 | ALTER COLUMN "state" DROP NOT NULL, 20 | ALTER COLUMN "zip_code" DROP NOT NULL; 21 | -------------------------------------------------------------------------------- /prisma/migrations/20220307144514_updated_order_date_default_to_current_date/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "orders" ALTER COLUMN "order_date" SET DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220307154817_updated_order_address_to_shipping_address/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `address` on the `orders` table. All the data in the column will be lost. 5 | - Added the required column `shipping_address` to the `orders` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "orders" DROP COLUMN "address", 10 | ADD COLUMN "shipping_address" TEXT NOT NULL; 11 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | // binaryTargets = ["native", "debian-openssl-1.1.x", "linux-musl"] // for linux 4 | binaryTargets = ["native", "linux-arm64-openssl-1.1.x"] 5 | previewFeatures = ["fullTextSearch"] 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | enum Role { 14 | SUPERADMIN 15 | ADMIN 16 | MODERATOR 17 | } 18 | 19 | model Admin { 20 | id Int @id @default(autoincrement()) 21 | username String @db.VarChar(50) 22 | email String @db.VarChar(50) @unique 23 | password String @db.VarChar(255) 24 | role Role @default(ADMIN) 25 | active Boolean @default(true) 26 | createdAt DateTime @default(now()) @map("created_at") 27 | updatedAt DateTime? @map("updated_at") 28 | 29 | @@map("admins") 30 | } 31 | 32 | model Category { 33 | id Int @id @default(autoincrement()) 34 | name String @db.VarChar(50) @unique 35 | description String? @db.VarChar(255) 36 | thumbnailImage String? @db.VarChar(100) @map("thumbnail_image") 37 | createdAt DateTime @default(now()) @map("created_at") 38 | updatedAt DateTime? @map("updated_at") 39 | products Product[] 40 | 41 | @@map("categories") 42 | } 43 | 44 | model Product { 45 | id Int @id @default(autoincrement()) 46 | name String @db.VarChar(50) 47 | price Decimal @db.Decimal(7, 2) 48 | discountPercent Int? @default(0) @map("discount_percent") 49 | description String @db.VarChar(255) 50 | detail String? 51 | category Category? @relation(fields: [categoryId], references: [id], onDelete: NoAction) 52 | categoryId Int? @map("category_id") 53 | image1 String @db.VarChar(100) @map("image_1") 54 | image2 String @db.VarChar(100) @map("image_2") 55 | stock Int? @default(0) 56 | createdAt DateTime @default(now()) @map("created_at") 57 | updatedAt DateTime? @map("updated_at") 58 | product OrderDetail[] 59 | tags Tag[] 60 | 61 | @@map("products") 62 | } 63 | 64 | model Tag { 65 | id Int @id @default(autoincrement()) 66 | name String @db.VarChar(50) 67 | products Product[] 68 | 69 | @@map("tags") 70 | } 71 | 72 | model Customer { 73 | id Int @id @default(autoincrement()) 74 | fullname String @db.VarChar(50) 75 | email String @db.VarChar(50) @unique 76 | password String @db.VarChar(255) 77 | shippingAddress String @map("default_shipping_address") 78 | phone String? @db.VarChar(20) 79 | orders Order[] 80 | resetPwdToken String? @db.VarChar(255) @unique @map("reset_password_token") 81 | resetPwdExpire BigInt? @db.BigInt @map("reset_password_expire") 82 | createdAt DateTime @default(now()) @map("created_at") 83 | updatedAt DateTime? @map("updated_at") 84 | 85 | @@map("customers") 86 | } 87 | 88 | model Order { 89 | orderNumber Int @id @default(autoincrement()) @map("order_number") 90 | customer Customer @relation(fields: [customerId], references: [id]) 91 | customerId Int @map("customer_id") 92 | shippingAddress String @map("shipping_address") 93 | township String? @db.VarChar(20) 94 | city String? @db.VarChar(20) 95 | state String? @db.VarChar(20) 96 | zipCode String? @db.VarChar(20) @map("zip_code") 97 | orderDate DateTime @db.Date @default(now()) @map("order_date") 98 | paymentType PaymentType @default(CASH_ON_DELIVERY) @map("payment_type") 99 | deliveryType DeliveryType @default(STORE_PICKUP) @map("delivery_type") 100 | totalPrice Float @map("total_price") 101 | deliveryDate DateTime @db.Date @map("delivery_date") 102 | orders OrderDetail[] 103 | 104 | @@map("orders") 105 | } 106 | 107 | enum DeliveryType { 108 | STORE_PICKUP 109 | YANGON 110 | OTHERS 111 | } 112 | 113 | enum PaymentType { 114 | CASH_ON_DELIVERY 115 | BANK_TRANSFER 116 | } 117 | 118 | model OrderDetail { 119 | order Order @relation(fields: [orderNumber], references: [orderNumber], onDelete: Cascade) 120 | orderNumber Int 121 | product Product @relation(fields: [productId], references: [id], onDelete: Cascade) 122 | productId Int 123 | quantity Int @default(1) 124 | 125 | @@id([orderNumber, productId]) 126 | @@map("order_details") 127 | } -------------------------------------------------------------------------------- /prisma/singleton.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { mockDeep, mockReset } from "jest-mock-extended"; 3 | import { DeepMockProxy } from "jest-mock-extended/lib/cjs/Mock"; 4 | 5 | import prisma from "./client"; 6 | 7 | require("jest-sorted"); 8 | 9 | jest.mock("./client", () => ({ 10 | __esModule: true, 11 | default: mockDeep(), 12 | })); 13 | 14 | beforeEach(() => { 15 | mockReset(prismaMock); 16 | }); 17 | 18 | export const prismaMock = prisma as unknown as DeepMockProxy; 19 | -------------------------------------------------------------------------------- /routers/admins.ts: -------------------------------------------------------------------------------- 1 | import Router from "express"; 2 | import { 3 | changePassword, 4 | createAdmin, 5 | deleteAdmin, 6 | getAdmin, 7 | getAdmins, 8 | getMe, 9 | loginAdmin, 10 | seedData, 11 | updateAdmin, 12 | updateAdminSelf, 13 | } from "../controllers/admins"; 14 | import { authorize, adminOnly } from "../middlewares/authHandler"; 15 | 16 | const router = Router(); 17 | 18 | router 19 | .route("/") 20 | .get(adminOnly, authorize("SUPERADMIN"), getAdmins) 21 | .post(adminOnly, authorize("SUPERADMIN"), createAdmin) 22 | .put(adminOnly, updateAdminSelf); 23 | 24 | router 25 | .get("/me", adminOnly, getMe) 26 | .post("/login", loginAdmin) 27 | .post("/seed", seedData) 28 | .put("/change-password", adminOnly, changePassword); 29 | 30 | router 31 | .route("/:id") 32 | .get(adminOnly, authorize("SUPERADMIN"), getAdmin) 33 | .put(adminOnly, authorize("SUPERADMIN"), updateAdmin) 34 | .delete(adminOnly, authorize("SUPERADMIN"), deleteAdmin); 35 | 36 | export default router; 37 | -------------------------------------------------------------------------------- /routers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | changePassword, 4 | forgotPassword, 5 | getMe, 6 | loginCustomer, 7 | registerCustomer, 8 | resetPassword, 9 | updateCustomerSelf, 10 | } from "../controllers/auth"; 11 | import { protect } from "../middlewares/authHandler"; 12 | 13 | const router = Router(); 14 | 15 | router 16 | .get("/me", protect, getMe) 17 | .post("/register", registerCustomer) 18 | .post("/login", loginCustomer) 19 | .put("/update-details", protect, updateCustomerSelf) 20 | .put("/change-password", protect, changePassword) 21 | .post("/forgot-password", forgotPassword) 22 | .put("/reset-password/:resetToken", resetPassword); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /routers/categories.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createCategory, 4 | deleteCategory, 5 | getCategories, 6 | getCategory, 7 | updateCategory, 8 | } from "../controllers/categories"; 9 | import { adminOnly } from "../middlewares/authHandler"; 10 | 11 | const router = Router(); 12 | 13 | router.route("/").get(getCategories).post(adminOnly, createCategory); 14 | 15 | router 16 | .route("/:id") 17 | .get(getCategory) 18 | .put(adminOnly, updateCategory) 19 | .delete(adminOnly, deleteCategory); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /routers/customers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | deleteCustomer, 4 | getCustomer, 5 | getCustomers, 6 | } from "../controllers/customers"; 7 | import { adminOnly } from "../middlewares/authHandler"; 8 | 9 | const router = Router(); 10 | 11 | router.get("/", adminOnly, getCustomers); 12 | 13 | router 14 | .get("/:id", adminOnly, getCustomer) 15 | .delete("/:id", adminOnly, deleteCustomer); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /routers/orders.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createOrder, 4 | deleteOrder, 5 | getOrder, 6 | getOrderDetails, 7 | getOrders, 8 | } from "../controllers/orders"; 9 | import { adminOnly } from "../middlewares/authHandler"; 10 | 11 | const router = Router(); 12 | 13 | router.route("/").get(adminOnly, getOrders).post(createOrder); 14 | 15 | // TESTing only 16 | router.route("/").patch(adminOnly, getOrderDetails); 17 | 18 | router.route("/:id").get(adminOnly, getOrder).delete(adminOnly, deleteOrder); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /routers/products.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createProduct, 4 | deleteProduct, 5 | getProduct, 6 | getProductCount, 7 | getProducts, 8 | searchProducts, 9 | updateProduct, 10 | } from "../controllers/products"; 11 | import { adminOnly } from "../middlewares/authHandler"; 12 | 13 | const router = Router(); 14 | 15 | router 16 | .get("/", getProducts) 17 | .get("/count", getProductCount) 18 | .get("/search", searchProducts) 19 | .post("/", adminOnly, createProduct); 20 | 21 | router 22 | .get("/:id", getProduct) 23 | .put("/:id", adminOnly, updateProduct) 24 | .delete("/:id", adminOnly, deleteProduct); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import app from "./app"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config(); 5 | 6 | const port = process.env.PORT || 3000; 7 | 8 | app.listen(port, () => { 9 | console.log(`Server is running and listening to port ${port}`); 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "ESNext" 11 | ] /* Specify library files to be included in the compilation. */, 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | "sourceMap": true /* Generates corresponding '.map' file. */, 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./dist" /* Redirect output structure to the directory. */, 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | "strict": true /* Enable all strict type-checking options. */, 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /utils/emailTemplate.ts: -------------------------------------------------------------------------------- 1 | const emailTemplate = ( 2 | orderNumber: number, 3 | total: number, 4 | deliveryAddress: string, 5 | deliveryDate: string, 6 | items: { name: string; price: string; qty: number }[] 7 | ) => { 8 | const itemRows = items.map( 9 | (item) => 10 | ` 11 | ${item.name} (${item.qty}) 12 | $${item.price} 13 | ` 14 | ); 15 | return ` 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 84 | 85 | 86 | 87 | For what reason would it be advisable for me to think about business content? That might be little bit risky to have crew member like them. 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | HARU Fashion 102 | Thank You For Your Order! 103 | 104 | 105 | 106 | 107 | Below is the detail information about your purchased items and order. Please contact us if you find anything wrong. 108 | 109 | 110 | 111 | 112 | 113 | 114 | Order Number # 115 | ${orderNumber} 116 | 117 | ${itemRows} 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | TOTAL 126 | $ ${total} 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | Delivery Address 144 | ${deliveryAddress} 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | Estimated Delivery Date 154 | ${new Date( 155 | deliveryDate 156 | ).toLocaleDateString()} 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | HARU Fashion 171 | 172 | 173 | 174 | No(7), Ground Floor, 175 | Malikha Building, Yadanar Road, 176 | Thingangyun, Yangon 177 | 178 | 179 | 180 | 181 | If you didn't create an account using this email address, please ignore this email or unsusbscribe. 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | `; 195 | }; 196 | 197 | export default emailTemplate; 198 | -------------------------------------------------------------------------------- /utils/errorObject.ts: -------------------------------------------------------------------------------- 1 | export type errObjType = { 2 | status: number; 3 | type: string; 4 | message: string; 5 | detail?: any[]; 6 | }; 7 | 8 | export const errorTypes = { 9 | notFound: "notFound", 10 | badRequest: "badRequest", 11 | internalError: "internalError", 12 | alreadyExists: "alreadyExists", 13 | missingField: "missingField", 14 | invalidQuery: "invalidQuery", 15 | invalidArgument: "invalidArgument", 16 | invalidToken: "invalidToken", 17 | expireToken: "expireToken", 18 | unauthorized: "unauthorized", 19 | forbidden: "forbidden", 20 | }; 21 | 22 | const errorObj = ( 23 | status: number, 24 | type: string, 25 | message: string, 26 | detail?: ErrorDetailType[] 27 | ) => ({ 28 | status, 29 | type, 30 | message, 31 | detail, 32 | }); 33 | 34 | /** 35 | * Internal Server Error 36 | * @description { 500, internalError, "internal server error" } 37 | */ 38 | export const defaultError = errorObj( 39 | 500, 40 | errorTypes.internalError, 41 | "internal server error" 42 | ); 43 | 44 | /** 45 | * Invalid Email Error 46 | * @description { 400, invalidArgument, "email is not valid" } 47 | */ 48 | export const invalidEmail = errorObj( 49 | 400, 50 | errorTypes.invalidArgument, 51 | "email is not valid" 52 | ); 53 | 54 | /** 55 | * Unauthorized Error 56 | * @description { 403, forbidden, "not authorized" } * 57 | */ 58 | export const unauthorizedError = errorObj( 59 | 403, 60 | errorTypes.forbidden, 61 | "not authorized" 62 | ); 63 | 64 | /** 65 | * Incorrect Credentials Error 66 | * @description { 401, unauthorized, "email or password is incorrect" } 67 | */ 68 | export const incorrectCredentialsError = { 69 | status: 401, 70 | type: errorTypes.unauthorized, 71 | message: "email or password is incorrect", 72 | }; 73 | 74 | /** 75 | * Role Error 76 | * @description { 400, invalidArgument, "role type is not valid" } 77 | */ 78 | export const roleError = errorObj( 79 | 400, 80 | errorTypes.invalidArgument, 81 | "role type is not valid", 82 | [ 83 | { 84 | code: "invalidRole", 85 | message: "role must be one of 'SUPERADMIN', 'ADMIN', and 'MODERATOR'", 86 | }, 87 | ] 88 | ); 89 | 90 | /** 91 | * Payment Type Error 92 | * @description { 400, invalidArgument, "payment type is not valid" } 93 | */ 94 | export const paymentTypeError = errorObj( 95 | 400, 96 | errorTypes.invalidArgument, 97 | "payment type is not valid", 98 | [ 99 | { 100 | code: "invalidPaymentType", 101 | message: 102 | "payment type must be one of 'CASH_ON_DELIVERY', 'BANK_TRANSFER'", 103 | }, 104 | ] 105 | ); 106 | 107 | /** 108 | * Delivery Type Error 109 | * @description { 400, invalidArgument, "delivery type is not valid" } 110 | */ 111 | export const deliveryTypeError = errorObj( 112 | 400, 113 | errorTypes.invalidArgument, 114 | "delivery type is not valid", 115 | [ 116 | { 117 | code: "invalidDeliveryType", 118 | message: 119 | "delivery type must be one of 'STORE_PICKUP', 'YANGON', or 'OTHERS'", 120 | }, 121 | ] 122 | ); 123 | 124 | /** 125 | * Auth Required Error 126 | * @description { 401, unauthorized, "authentication required" } 127 | */ 128 | // unauthAccess 129 | export const authRequiredError = errorObj( 130 | 401, 131 | errorTypes.unauthorized, 132 | "authentication required" 133 | ); 134 | 135 | /** 136 | * 404 Resource Not Found Error 137 | * @param resource - resource to return as message 138 | * @return errorObj - { 404, notFound, "${resource} not found" } 139 | */ 140 | export const resource404Error = (resource: string = "resource") => 141 | errorObj(404, errorTypes.notFound, `${resource} not found`); 142 | 143 | /** 144 | * ID Not Specified Error 145 | * @description { 400, badRequest, "id not specified in the request" } 146 | */ 147 | export const idNotSpecifiedError = errorObj( 148 | 400, 149 | errorTypes.badRequest, 150 | "id not specified in the request" 151 | ); 152 | 153 | /** 154 | * Invalid Query Error 155 | * @description { 400, invalidQuery, "one or more url query is invalid" } 156 | */ 157 | export const invalidQuery = errorObj( 158 | 400, 159 | errorTypes.invalidQuery, 160 | "one or more url query is invalid" 161 | ); 162 | 163 | export type ErrorDetailType = { 164 | code: string; 165 | message: string; 166 | }; 167 | 168 | /** 169 | * Invalid Argument Detail Error 170 | * @return Object - { code: "missingSomething", message: "some field is missing"} 171 | */ 172 | export const invalidArgDetail = (str: string) => { 173 | return { 174 | code: `missing${str.charAt(0).toUpperCase()}${str.slice(1)}`, 175 | message: `${str} field is missing`, 176 | }; 177 | }; 178 | 179 | /** 180 | * Invalid Argument Error 181 | * @return Object - { 400, invalidArgument, "invalid one or more argument(s)"} 182 | */ 183 | export const invalidArgError = (detail: ErrorDetailType[]) => 184 | errorObj( 185 | 400, 186 | errorTypes.invalidArgument, 187 | "invalid one or more argument(s)", 188 | detail 189 | ); 190 | 191 | export const missingField = (field: string) => 192 | errorObj(400, errorTypes.missingField, `${field} field is missing`); 193 | 194 | /** 195 | * Invalid Token Error 196 | * @return Object - {400, invalidToken, "token is invalid"} 197 | */ 198 | export const invalidTokenError = errorObj( 199 | 400, 200 | errorTypes.invalidToken, 201 | "token is invalid" 202 | ); 203 | 204 | /** 205 | * Expire Token Error 206 | * @return Object - {400, invalidToken, "token is invalid"} 207 | */ 208 | export const expireTokenError = errorObj( 209 | 400, 210 | errorTypes.expireToken, 211 | "token is expired" 212 | ); 213 | 214 | export default errorObj; 215 | 216 | // { 217 | // "status": 500, 218 | // "type": "internalError", 219 | // "message": "Internal Server Error" 220 | // "detail": [] 221 | // } 222 | 223 | // { 224 | // status: 404, 225 | // type: "notFound", 226 | // message: "Page Not Found", 227 | // detail: [] 228 | // } 229 | 230 | // "error": { 231 | // "status": 400, 232 | // "type": "invalidArgument", 233 | // "message": "invalid category id" 234 | // } 235 | 236 | // "error": { 237 | // "status": 400, 238 | // "type": "invalidArgument", 239 | // "message": "invalid one or more argument(s)", 240 | // "detail": [ 241 | // { 242 | // "code": "missingName", 243 | // "message": "name field is missing" 244 | // }, 245 | // { 246 | // "code": "missingPrice", 247 | // "message": "price field is missing" 248 | // }, 249 | // { 250 | // "code": "missingDescription", 251 | // "message": "description field is missing" 252 | // }, 253 | // { 254 | // "code": "missingImage1", 255 | // "message": "image1 field is missing" 256 | // }, 257 | // { 258 | // "code": "missingImage2", 259 | // "message": "image2 field is missing" 260 | // } 261 | // ] 262 | // } 263 | -------------------------------------------------------------------------------- /utils/errorResponse.ts: -------------------------------------------------------------------------------- 1 | import { errObjType } from "./errorObject"; 2 | 3 | class ErrorResponse extends Error { 4 | public errObj: errObjType; 5 | public statusCode: number; 6 | constructor(errObj: errObjType, statusCode: number) { 7 | // super(errObj); 8 | super(); 9 | this.errObj = errObj; 10 | this.statusCode = statusCode; 11 | } 12 | } 13 | 14 | export default ErrorResponse; 15 | -------------------------------------------------------------------------------- /utils/errors.txt: -------------------------------------------------------------------------------- 1 | "error": { 2 | "status": 400, 3 | "type": "invalidArgument", 4 | "message": "invalid category id" 5 | } 6 | 7 | "error": { 8 | "status": 400, 9 | "type": "invalidArgument", 10 | "message": "invalid one or more argument(s)", 11 | "detail": [ 12 | { 13 | "code": "missingName", 14 | "message": "name field is missing" 15 | }, 16 | ----- 17 | ] 18 | } -------------------------------------------------------------------------------- /utils/extendedRequest.ts: -------------------------------------------------------------------------------- 1 | import { Admin, Customer } from ".prisma/client"; 2 | import { Request } from "express"; 3 | export interface ExtendedRequest extends Request { 4 | user?: Customer | null; // or any other type 5 | admin?: Admin | null; 6 | } 7 | -------------------------------------------------------------------------------- /utils/helperFunctions.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import bcrypt from "bcryptjs"; 3 | import crypto from "crypto"; 4 | import { NextFunction } from "express"; 5 | import { 6 | invalidArgDetail, 7 | invalidArgError, 8 | ErrorDetailType, 9 | } from "./errorObject"; 10 | import ErrorResponse from "./errorResponse"; 11 | 12 | type OrderType = { [key: string]: string }; 13 | type FilteredType = { [key: string]: number }; 14 | export type ProductSelectType = { 15 | id: boolean; 16 | name: boolean; 17 | price: boolean; 18 | discountPercent: boolean; 19 | description: boolean; 20 | detail: boolean; 21 | categoryId: boolean; 22 | image1: boolean; 23 | image2: boolean; 24 | stock: boolean; 25 | createdAt: boolean; 26 | updatedAt: boolean; 27 | tags: boolean; 28 | category: boolean; 29 | }; 30 | 31 | /** 32 | * Receive comma seperated string and return { string: true } 33 | * @param query - comma seperated string 34 | * @returns object { string: true, string: true } 35 | * @example 'name,price,stock' => { name: true, price: true, stock: true } 36 | */ 37 | export const selectedQuery = (query: string) => 38 | query.split(",").reduce((a, v) => ({ ...a, [v.trim()]: true }), {}); 39 | 40 | /** 41 | * Select all fields from product table 42 | * @returns object { id: true, name: true, ... , updatedAt: true } 43 | * @example { name: true, price: true, stock: true , ... } 44 | */ 45 | export const selectAllProductField = () => ({ 46 | id: true, 47 | name: true, 48 | price: true, 49 | discountPercent: true, 50 | description: true, 51 | detail: true, 52 | categoryId: true, 53 | image1: true, 54 | image2: true, 55 | stock: true, 56 | createdAt: true, 57 | updatedAt: true, 58 | }); 59 | 60 | /** 61 | * Receive string and return array of { key: value } pairs 62 | * @param query - query string 63 | * @returns array of object [ {key:value}, etc] 64 | * @example 'price.desc,name' => [ { price: 'desc' }, { name: 'asc' } ] 65 | */ 66 | export const orderedQuery = (query: string) => { 67 | let orderArray: OrderType[] = []; 68 | const sortLists = query.split(","); 69 | sortLists.forEach((sl) => { 70 | const obj: OrderType = {}; 71 | 72 | const fields = sl.split("."); 73 | obj[fields[0]] = fields[1] || "asc"; 74 | orderArray = [...orderArray, obj]; 75 | }); 76 | return orderArray; 77 | }; 78 | 79 | /** 80 | * Receive string or string[] and return array of { key: value } pairs 81 | * @param query - query string or string[] 82 | * @returns array of object [ {key: value}, etc ] 83 | * @example ['gte:50','lt:100'] => [ { gte: 50 }, { lt: 100 } ] 84 | * @example 'gte:50' => [ { gte: 50 } ] 85 | */ 86 | export const filteredQty = (query: string | string[]) => { 87 | const obj: FilteredType = {}; 88 | const obj2: FilteredType = {}; 89 | let filteredValue: FilteredType[] = []; 90 | if (typeof query === "string") { 91 | const fields = query.split(":"); 92 | obj[fields[0]] = parseFloat(fields[1]); 93 | filteredValue = [...filteredValue, obj]; 94 | } 95 | if (typeof query === "object") { 96 | const fields = (query as string[])[0].split(":"); 97 | obj[fields[0]] = parseFloat(fields[1]); 98 | filteredValue = [...filteredValue, obj]; 99 | 100 | const fields2 = (query as string[])[1].split(":"); 101 | obj2[fields2[0]] = parseFloat(fields2[1]); 102 | filteredValue = [...filteredValue, obj2]; 103 | } 104 | return filteredValue; 105 | }; 106 | 107 | /** 108 | * Check required fields 109 | * @param requiredObj - required fields as an object 110 | * @param next - express next function 111 | * @returns false (hasError) | ErrorResponse 112 | */ 113 | export const checkRequiredFields = ( 114 | requiredObj: { [key: string]: string | undefined }, 115 | next: NextFunction 116 | ) => { 117 | let errorArray: ErrorDetailType[] = []; 118 | for (const field in requiredObj) { 119 | if (!requiredObj[field]) { 120 | errorArray = [...errorArray, invalidArgDetail(field)]; 121 | } 122 | } 123 | if (errorArray.length === 0) { 124 | return false; 125 | } else { 126 | return next(new ErrorResponse(invalidArgError(errorArray), 400)); 127 | } 128 | }; 129 | 130 | /** 131 | * Check if a number is positive integer 132 | * @param num - number to be checked 133 | * @returns true | false 134 | */ 135 | export const isIntegerAndPositive = (num: number) => num % 1 === 0 && num > 0; 136 | 137 | /** 138 | * Check email is valid 139 | * @param email - email to be checked 140 | * @returns true | false 141 | */ 142 | export const validateEmail = (email: string) => { 143 | const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/; 144 | return emailRegex.test(String(email).toLowerCase()); 145 | }; 146 | 147 | /** 148 | * Check role 149 | * @param role 150 | * @returns true | false 151 | */ 152 | export const checkRole = (role: string) => { 153 | const allowedRoles = ["SUPERADMIN", "ADMIN", "MODERATOR"]; 154 | return allowedRoles.includes(role) ? true : false; 155 | }; 156 | 157 | /** 158 | * Check payment type 159 | * @param paymentType 160 | * @returns true | false 161 | */ 162 | export const checkPaymentType = (paymentType: string) => { 163 | const allowedPaymentTypes = ["CASH_ON_DELIVERY", "BANK_TRANSFER"]; 164 | return allowedPaymentTypes.includes(paymentType) ? true : false; 165 | }; 166 | 167 | /** 168 | * Check delivery type 169 | * @param deliveryType 170 | * @returns true | false 171 | */ 172 | export const checkDeliveryType = (deliveryType: string) => { 173 | const allowedDeliveryTypes = ["STORE_PICKUP", "YANGON", "OTHERS"]; 174 | return allowedDeliveryTypes.includes(deliveryType) ? true : false; 175 | }; 176 | 177 | /** 178 | * Hash plain text password 179 | * @param password - plain password 180 | * @returns hashed password (Promise) 181 | */ 182 | export const hashPassword = (password: string) => bcrypt.hash(password, 10); 183 | 184 | /** 185 | * Compare input password with stored hashed password 186 | * @param inputPassword - input password 187 | * @param storedPassword - hashed password stored in db 188 | * @returns true | false (Promise) 189 | */ 190 | export const comparePassword = (inputPwd: string, storedPwd: string) => 191 | bcrypt.compare(inputPwd, storedPwd); 192 | 193 | /** 194 | * Generate JsonWebToken 195 | * @param {number} id - User ID 196 | * @param {string} email - User Email 197 | * @returns jwt 198 | */ 199 | export const generateToken = (id: number, email: string) => 200 | jwt.sign( 201 | { 202 | iat: Math.floor(Date.now() / 1000) - 30, 203 | id, 204 | email, 205 | }, 206 | process.env.JWT_SECRET as string, 207 | { expiresIn: "1h" } 208 | ); 209 | 210 | /** 211 | * Generate Reset Password Token 212 | * @returns Array - [resetToken,resetPwdToken,resetPwdExpire] 213 | */ 214 | export const generateResetPwdToken = () => { 215 | // Generate token 216 | const resetToken = crypto.randomBytes(20).toString("hex"); 217 | 218 | // Hash token and set to resetPwdToken field 219 | const resetPwdToken = crypto 220 | .createHash("sha256") 221 | .update(resetToken) 222 | .digest("hex"); 223 | 224 | // Set expire 225 | const resetPwdExpire = Date.now() + 10 * 60 * 1000; 226 | 227 | return [resetToken, resetPwdToken, resetPwdExpire]; 228 | }; 229 | -------------------------------------------------------------------------------- /utils/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import sgMail from "@sendgrid/mail"; 3 | 4 | type Email = { 5 | email: string; 6 | subject: string; 7 | message: string; 8 | }; 9 | 10 | /** 11 | * Send Mail Function 12 | * @param emailObject - { Email, subject, message } 13 | */ 14 | const sendMail = async ({ email, subject, message }: Email) => { 15 | console.log(process.env.SENDGRID_API_KEY); 16 | sgMail.setApiKey(process.env.SENDGRID_API_KEY!); 17 | const msg = { 18 | to: email, 19 | from: process.env.SENDGRID_VERIFIED_SENDER!, 20 | subject: subject, 21 | text: message, 22 | html: message, 23 | }; 24 | sgMail 25 | .send(msg) 26 | .then(() => { 27 | console.log("Email sent"); 28 | }) 29 | .catch((error) => { 30 | console.log("Email not sent"); 31 | console.error(error); 32 | }); 33 | 34 | /* ===== MailTrap Version ===== */ 35 | // const transporter = nodemailer.createTransport({ 36 | // host: process.env.SMTP_HOST, 37 | // port: process.env.SMTP_PORT as number | undefined, 38 | // auth: { 39 | // user: process.env.SMTP_USER, 40 | // pass: process.env.SMTP_PASS, 41 | // }, 42 | // }); 43 | 44 | // let info = await transporter.sendMail({ 45 | // from: `"${process.env.FROM_NAME}" <${process.env.FROM_MAIL}>`, 46 | // to: email, 47 | // subject: subject, 48 | // text: message, 49 | // }); 50 | 51 | // console.log(`Message send: ${info.messageId}`); 52 | }; 53 | 54 | export default sendMail; 55 | --------------------------------------------------------------------------------
⬇️
Below is the detail information about your purchased items and order. Please contact us if you find anything wrong.
Delivery Address
${deliveryAddress}
Estimated Delivery Date
${new Date( 155 | deliveryDate 156 | ).toLocaleDateString()}
No(7), Ground Floor, 175 | Malikha Building, Yadanar Road, 176 | Thingangyun, Yangon
If you didn't create an account using this email address, please ignore this email or unsusbscribe.