├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── doc ├── address.md ├── contact.md └── user.md ├── manual.http ├── nest-cli.json ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20240312145331_create_table_users │ │ └── migration.sql │ ├── 20240312145655_create_table_contacts │ │ └── migration.sql │ ├── 20240312150000_create_table_addresses │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── address │ ├── address.controller.ts │ ├── address.module.ts │ ├── address.service.ts │ └── address.validation.ts ├── app.module.ts ├── common │ ├── auth.decorator.ts │ ├── auth.middleware.ts │ ├── common.module.ts │ ├── error.filter.ts │ ├── prisma.service.ts │ └── validation.service.ts ├── contact │ ├── contact.controller.ts │ ├── contact.module.ts │ ├── contact.service.ts │ └── contact.validation.ts ├── main.ts ├── model │ ├── address.model.ts │ ├── contact.model.ts │ ├── user.model.ts │ └── web.model.ts └── user │ ├── user.controller.ts │ ├── user.module.ts │ ├── user.service.ts │ └── user.validation.ts ├── test ├── address.spec.ts ├── contact.spec.ts ├── test.module.ts ├── test.service.ts └── user.spec.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /doc/address.md: -------------------------------------------------------------------------------- 1 | # Address API Spec 2 | 3 | ## Create Address 4 | 5 | Endpoint : POST /api/contacts/:contactId/addresses 6 | 7 | Headers : 8 | - Authorization: token 9 | 10 | Request Body : 11 | 12 | ```json 13 | { 14 | "street" : "Jalan Contoh, optional", 15 | "city" : "Kota, optional", 16 | "province" : "Provinsi, optional", 17 | "country" : "Negara Apa", 18 | "postal_code" : "123123" 19 | } 20 | ``` 21 | 22 | Response Body 23 | 24 | ```json 25 | { 26 | "data" : { 27 | "id" : 1, 28 | "street" : "Jalan Contoh, optional", 29 | "city" : "Kota, optional", 30 | "province" : "Provinsi, optional", 31 | "country" : "Negara Apa", 32 | "postal_code" : "123123" 33 | } 34 | } 35 | ``` 36 | 37 | ## Get Address 38 | 39 | Endpoint : GET /api/contacts/:contactId/addresses/:addressId 40 | 41 | Headers : 42 | - Authorization: token 43 | 44 | Response Body 45 | 46 | ```json 47 | { 48 | "data" : { 49 | "id" : 1, 50 | "street" : "Jalan Contoh, optional", 51 | "city" : "Kota, optional", 52 | "province" : "Provinsi, optional", 53 | "country" : "Negara Apa", 54 | "postal_code" : "123123" 55 | } 56 | } 57 | ``` 58 | 59 | ## Update Address 60 | 61 | Endpoint : PUT /api/contacts/:contactId/addresses/:addressId 62 | 63 | Headers : 64 | - Authorization: token 65 | 66 | Request Body : 67 | 68 | ```json 69 | { 70 | "street" : "Jalan Contoh, optional", 71 | "city" : "Kota, optional", 72 | "province" : "Provinsi, optional", 73 | "country" : "Negara Apa", 74 | "postal_code" : "123123" 75 | } 76 | ``` 77 | 78 | Response Body 79 | 80 | ```json 81 | { 82 | "data" : { 83 | "id" : 1, 84 | "street" : "Jalan Contoh, optional", 85 | "city" : "Kota, optional", 86 | "province" : "Provinsi, optional", 87 | "country" : "Negara Apa", 88 | "postal_code" : "123123" 89 | } 90 | } 91 | ``` 92 | 93 | ## Remove Address 94 | 95 | Endpoint : DELETE /api/contacts/:contactId/addresses/:addressId 96 | 97 | Headers : 98 | - Authorization: token 99 | 100 | Response Body 101 | 102 | ```json 103 | { 104 | "data" : true 105 | } 106 | ``` 107 | 108 | ## List Addresses 109 | 110 | Endpoint : GET /api/contacts/:contactId/addresses 111 | 112 | Headers : 113 | - Authorization: token 114 | 115 | Response Body 116 | 117 | ```json 118 | { 119 | "data" : [ 120 | { 121 | "id" : 1, 122 | "street" : "Jalan Contoh, optional", 123 | "city" : "Kota, optional", 124 | "province" : "Provinsi, optional", 125 | "country" : "Negara Apa", 126 | "postal_code" : "123123" 127 | }, 128 | { 129 | "id" : 2, 130 | "street" : "Jalan Contoh, optional", 131 | "city" : "Kota, optional", 132 | "province" : "Provinsi, optional", 133 | "country" : "Negara Apa", 134 | "postal_code" : "123123" 135 | } 136 | ] 137 | } 138 | ``` 139 | -------------------------------------------------------------------------------- /doc/contact.md: -------------------------------------------------------------------------------- 1 | # Contact API Spec 2 | 3 | ## Create Contact 4 | 5 | Endpoint : POST /api/contacts 6 | 7 | Headers : 8 | - Authorization: token 9 | 10 | Request Body : 11 | 12 | ```json 13 | { 14 | "first_name" : "Eko Kurniawan", 15 | "last_name" : "Khannedy", 16 | "email" : "eko@example.com", 17 | "phone" : "08999999999" 18 | } 19 | ``` 20 | 21 | Response Body : 22 | 23 | ```json 24 | { 25 | "data" : { 26 | "id" : 1, 27 | "first_name" : "Eko Kurniawan", 28 | "last_name" : "Khannedy", 29 | "email" : "eko@example.com", 30 | "phone" : "08999999999" 31 | } 32 | } 33 | ``` 34 | 35 | ## Get Contact 36 | 37 | Endpoint : GET /api/contacts/:contactId 38 | 39 | Headers : 40 | - Authorization: token 41 | 42 | Response Body : 43 | 44 | ```json 45 | { 46 | "data" : { 47 | "id" : 1, 48 | "first_name" : "Eko Kurniawan", 49 | "last_name" : "Khannedy", 50 | "email" : "eko@example.com", 51 | "phone" : "08999999999" 52 | } 53 | } 54 | ``` 55 | 56 | ## Update Contact 57 | 58 | Endpoint : PUT /api/contacts/:contactId 59 | 60 | Headers : 61 | - Authorization: token 62 | 63 | Request Body : 64 | 65 | ```json 66 | { 67 | "first_name" : "Eko Kurniawan", 68 | "last_name" : "Khannedy", 69 | "email" : "eko@example.com", 70 | "phone" : "08999999999" 71 | } 72 | ``` 73 | 74 | Response Body : 75 | 76 | ```json 77 | { 78 | "data" : { 79 | "id" : 1, 80 | "first_name" : "Eko Kurniawan", 81 | "last_name" : "Khannedy", 82 | "email" : "eko@example.com", 83 | "phone" : "08999999999" 84 | } 85 | } 86 | ``` 87 | 88 | ## Remove Contact 89 | 90 | Endpoint : DELETE /api/contacts/:contactId 91 | 92 | Headers : 93 | - Authorization: token 94 | 95 | Response Body : 96 | 97 | ```json 98 | { 99 | "data" : true 100 | } 101 | ``` 102 | 103 | ## Search Contact 104 | 105 | Endpoint : GET /api/contacts 106 | 107 | Headers : 108 | - Authorization: token 109 | 110 | Query Params : 111 | - name: string, contact first name or contact last name, optional 112 | - phone: string, contact phone, optional 113 | - email: string, contact email, optional 114 | - page: number, default 1 115 | - size: number, default 10 116 | 117 | Response Body : 118 | 119 | ```json 120 | { 121 | "data" : [ 122 | { 123 | "id" : 1, 124 | "first_name" : "Eko Kurniawan", 125 | "last_name" : "Khannedy", 126 | "email" : "eko@example.com", 127 | "phone" : "08999999999" 128 | }, 129 | { 130 | "id" : 2, 131 | "first_name" : "Eko Kurniawan", 132 | "last_name" : "Khannedy", 133 | "email" : "eko@example.com", 134 | "phone" : "08999999999" 135 | } 136 | ], 137 | "paging" : { 138 | "current_page" : 1, 139 | "total_page" : 10, 140 | "size" : 10 141 | } 142 | } 143 | ``` 144 | -------------------------------------------------------------------------------- /doc/user.md: -------------------------------------------------------------------------------- 1 | # User API Spec 2 | 3 | ## Register User 4 | 5 | Endpoint : POST /api/users 6 | 7 | Request Body : 8 | 9 | ```json 10 | { 11 | "username" : "khannedy", 12 | "password" : "rahasia", 13 | "name" : "Eko Khannedy" 14 | } 15 | ``` 16 | 17 | Response Body (Success) : 18 | 19 | ```json 20 | { 21 | "data" : { 22 | "username" : "khannedy", 23 | "name" : "Eko Khannedy" 24 | } 25 | } 26 | ``` 27 | 28 | Response Body (Failed) : 29 | 30 | ```json 31 | { 32 | "errors" : "Username already registered" 33 | } 34 | ``` 35 | 36 | ## Login User 37 | 38 | Endpoint : POST /api/users/login 39 | 40 | Request Body : 41 | 42 | ```json 43 | { 44 | "username" : "khannedy", 45 | "password" : "rahasia" 46 | } 47 | ``` 48 | 49 | Response Body (Success) : 50 | 51 | ```json 52 | { 53 | "data" : { 54 | "username" : "khannedy", 55 | "name" : "Eko Khannedy", 56 | "token" : "session_id_generated" 57 | } 58 | } 59 | ``` 60 | 61 | Response Body (Failed) : 62 | 63 | ```json 64 | { 65 | "errors" : "Username or password is wrong" 66 | } 67 | ``` 68 | 69 | ## Get User 70 | 71 | Endpoint : GET /api/users/current 72 | 73 | Headers : 74 | - Authorization: token 75 | 76 | Response Body (Success) : 77 | 78 | ```json 79 | { 80 | "data" : { 81 | "username" : "khannedy", 82 | "name" : "Eko Khannedy" 83 | } 84 | } 85 | ``` 86 | 87 | Response Body (Failed) : 88 | 89 | ```json 90 | { 91 | "errors" : "Unauthorized" 92 | } 93 | ``` 94 | 95 | ## Update User 96 | 97 | Endpoint : PATCH /api/users/current 98 | 99 | Headers : 100 | - Authorization: token 101 | 102 | Request Body : 103 | 104 | ```json 105 | { 106 | "password" : "rahasia", // optional, if want to change password 107 | "name" : "Eko Khannedy" // optional, if want to change name 108 | } 109 | ``` 110 | 111 | Response Body (Success) : 112 | 113 | ```json 114 | { 115 | "data" : { 116 | "username" : "khannedy", 117 | "name" : "Eko Khannedy" 118 | } 119 | } 120 | ``` 121 | 122 | ## Logout User 123 | 124 | Endpoint : DELETE /api/users/current 125 | 126 | Headers : 127 | - Authorization: token 128 | 129 | Response Body (Success) : 130 | 131 | ```json 132 | { 133 | "data" : true 134 | } 135 | ``` 136 | -------------------------------------------------------------------------------- /manual.http: -------------------------------------------------------------------------------- 1 | POST http://localhost:3000/api/users 2 | Content-Type: application/json 3 | Accept: application/json 4 | 5 | { 6 | "username" : "khannedy", 7 | "name" : "Eko Khannedy", 8 | "password" : "rahasia" 9 | } 10 | 11 | ### Login 12 | 13 | POST http://localhost:3000/api/users/login 14 | Content-Type: application/json 15 | Accept: application/json 16 | 17 | { 18 | "username" : "khannedy", 19 | "password" : "rahasia12345" 20 | } 21 | 22 | ### Get User 23 | GET http://localhost:3000/api/users/current 24 | Accept: application/json 25 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 26 | 27 | ### Logout User 28 | DELETE http://localhost:3000/api/users/current 29 | Accept: application/json 30 | Authorization: 11c4957d-6028-4b9f-895b-8232782a10a9 31 | 32 | ### Update User 33 | PATCH http://localhost:3000/api/users/current 34 | Accept: application/json 35 | Content-Type: application/json 36 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 37 | 38 | { 39 | "password" : "rahasia12345" 40 | } 41 | 42 | ### Create contact 43 | POST http://localhost:3000/api/contacts 44 | Content-Type: application/json 45 | Accept: application/json 46 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 47 | 48 | { 49 | "first_name" : "Joko", 50 | "last_name" : "Morro", 51 | "email" : "joko@example.com", 52 | "phone" : "089999999" 53 | } 54 | 55 | ### Update contact 56 | PUT http://localhost:3000/api/contacts/332 57 | Content-Type: application/json 58 | Accept: application/json 59 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 60 | 61 | { 62 | "first_name" : "Budi", 63 | "last_name" : "Morro", 64 | "email" : "budi@example.com", 65 | "phone" : "089999999" 66 | } 67 | 68 | 69 | ### Get contact 70 | GET http://localhost:3000/api/contacts/332 71 | Accept: application/json 72 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 73 | 74 | ### Delete contact 75 | DELETE http://localhost:3000/api/contacts/333 76 | Accept: application/json 77 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 78 | 79 | ### Search contact 80 | GET http://localhost:3000/api/contacts 81 | Accept: application/json 82 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 83 | 84 | ### Create Address 85 | POST http://localhost:3000/api/contacts/332/addresses 86 | Content-Type: application/json 87 | Accept: application/json 88 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 89 | 90 | { 91 | "street" : "jalan contoh", 92 | "city" : "kota contoh", 93 | "province" : "provinsi contoh", 94 | "country" : "negara contoh", 95 | "postal_code" : "23434" 96 | } 97 | 98 | ### Get Address 99 | GET http://localhost:3000/api/contacts/332/addresses/171 100 | Accept: application/json 101 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 102 | 103 | ### List Address 104 | GET http://localhost:3000/api/contacts/332/addresses 105 | Accept: application/json 106 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 107 | 108 | ### Update Address 109 | PUT http://localhost:3000/api/contacts/332/addresses/171 110 | Content-Type: application/json 111 | Accept: application/json 112 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 113 | 114 | { 115 | "street" : "jalan contoh updated", 116 | "city" : "kota contoh updated", 117 | "province" : "provinsi contoh", 118 | "country" : "negara contoh", 119 | "postal_code" : "23434" 120 | } 121 | 122 | ### Remove Address 123 | DELETE http://localhost:3000/api/contacts/332/addresses/172 124 | Accept: application/json 125 | Authorization: f5fc1bb9-a979-4f95-9173-8d42d0ac8d52 126 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "belajar-nestjs-restful-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest --runInBand", 17 | "test:watch": "jest --watch --runInBand", 18 | "test:cov": "jest --coverage --runInBand", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" 20 | }, 21 | "dependencies": { 22 | "@nestjs/common": "^10.0.0", 23 | "@nestjs/config": "^3.2.0", 24 | "@nestjs/core": "^10.0.0", 25 | "@nestjs/platform-express": "^10.0.0", 26 | "@prisma/client": "^5.10.2", 27 | "bcrypt": "^5.1.1", 28 | "nest-winston": "^1.9.4", 29 | "reflect-metadata": "^0.2.0", 30 | "rxjs": "^7.8.1", 31 | "uuid": "^9.0.1", 32 | "zod": "^3.22.4" 33 | }, 34 | "devDependencies": { 35 | "@nestjs/cli": "^10.0.0", 36 | "@nestjs/schematics": "^10.0.0", 37 | "@nestjs/testing": "^10.0.0", 38 | "@types/bcrypt": "^5.0.2", 39 | "@types/express": "^4.17.17", 40 | "@types/jest": "^29.5.2", 41 | "@types/node": "^20.3.1", 42 | "@types/supertest": "^6.0.0", 43 | "@types/uuid": "^9.0.8", 44 | "@typescript-eslint/eslint-plugin": "^6.0.0", 45 | "@typescript-eslint/parser": "^6.0.0", 46 | "eslint": "^8.42.0", 47 | "eslint-config-prettier": "^9.0.0", 48 | "eslint-plugin-prettier": "^5.0.0", 49 | "jest": "^29.5.0", 50 | "prettier": "^3.0.0", 51 | "prisma": "^5.10.2", 52 | "source-map-support": "^0.5.21", 53 | "supertest": "^6.3.3", 54 | "ts-jest": "^29.1.0", 55 | "ts-loader": "^9.4.3", 56 | "ts-node": "^10.9.1", 57 | "tsconfig-paths": "^4.2.0", 58 | "typescript": "^5.1.3" 59 | }, 60 | "jest": { 61 | "moduleFileExtensions": [ 62 | "js", 63 | "json", 64 | "ts" 65 | ], 66 | "rootDir": "test", 67 | "testRegex": ".*\\.spec\\.ts$", 68 | "transform": { 69 | "^.+\\.(t|j)s$": "ts-jest" 70 | }, 71 | "collectCoverageFrom": [ 72 | "**/*.(t|j)s" 73 | ], 74 | "coverageDirectory": "../coverage", 75 | "testEnvironment": "node" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /prisma/migrations/20240312145331_create_table_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `users` ( 3 | `username` VARCHAR(100) NOT NULL, 4 | `password` VARCHAR(100) NOT NULL, 5 | `name` VARCHAR(100) NOT NULL, 6 | `token` VARCHAR(100) NULL, 7 | 8 | PRIMARY KEY (`username`) 9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20240312145655_create_table_contacts/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `contacts` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `first_name` VARCHAR(100) NOT NULL, 5 | `last_name` VARCHAR(100) NULL, 6 | `email` VARCHAR(100) NULL, 7 | `phone` VARCHAR(20) NULL, 8 | `username` VARCHAR(100) NOT NULL, 9 | 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- AddForeignKey 14 | ALTER TABLE `contacts` ADD CONSTRAINT `contacts_username_fkey` FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON DELETE RESTRICT ON UPDATE CASCADE; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20240312150000_create_table_addresses/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `addresses` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `street` VARCHAR(255) NULL, 5 | `city` VARCHAR(100) NULL, 6 | `province` VARCHAR(100) NULL, 7 | `country` VARCHAR(100) NOT NULL, 8 | `postal_code` VARCHAR(10) NOT NULL, 9 | `contact_id` INTEGER NOT NULL, 10 | 11 | PRIMARY KEY (`id`) 12 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 13 | 14 | -- AddForeignKey 15 | ALTER TABLE `addresses` ADD CONSTRAINT `addresses_contact_id_fkey` FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /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 = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "mysql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model User { 17 | username String @id @db.VarChar(100) 18 | password String @db.VarChar(100) 19 | name String @db.VarChar(100) 20 | token String? @db.VarChar(100) 21 | 22 | contacts Contact[] 23 | 24 | @@map("users") 25 | } 26 | 27 | model Contact { 28 | id Int @id @default(autoincrement()) 29 | first_name String @db.VarChar(100) 30 | last_name String? @db.VarChar(100) 31 | email String? @db.VarChar(100) 32 | phone String? @db.VarChar(20) 33 | username String @db.VarChar(100) 34 | 35 | user User @relation(fields: [username], references: [username]) 36 | addresses Address[] 37 | 38 | @@map("contacts") 39 | } 40 | 41 | model Address { 42 | id Int @id @default(autoincrement()) 43 | street String? @db.VarChar(255) 44 | city String? @db.VarChar(100) 45 | province String? @db.VarChar(100) 46 | country String @db.VarChar(100) 47 | postal_code String @db.VarChar(10) 48 | contact_id Int 49 | 50 | contact Contact @relation(fields: [contact_id], references: [id]) 51 | 52 | @@map("addresses") 53 | } 54 | -------------------------------------------------------------------------------- /src/address/address.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Param, 8 | ParseIntPipe, 9 | Post, 10 | Put, 11 | } from '@nestjs/common'; 12 | import { AddressService } from './address.service'; 13 | import { WebResponse } from '../model/web.model'; 14 | import { 15 | AddressResponse, 16 | CreateAddressRequest, 17 | GetAddressRequest, 18 | RemoveAddressRequest, 19 | UpdateAddressRequest, 20 | } from '../model/address.model'; 21 | import { User } from '@prisma/client'; 22 | import { Auth } from '../common/auth.decorator'; 23 | 24 | @Controller('/api/contacts/:contactId/addresses') 25 | export class AddressController { 26 | constructor(private addressService: AddressService) {} 27 | 28 | @Post() 29 | @HttpCode(200) 30 | async create( 31 | @Auth() user: User, 32 | @Param('contactId', ParseIntPipe) contactId: number, 33 | @Body() request: CreateAddressRequest, 34 | ): Promise> { 35 | request.contact_id = contactId; 36 | const result = await this.addressService.create(user, request); 37 | return { 38 | data: result, 39 | }; 40 | } 41 | 42 | @Get('/:addressId') 43 | @HttpCode(200) 44 | async get( 45 | @Auth() user: User, 46 | @Param('contactId', ParseIntPipe) contactId: number, 47 | @Param('addressId', ParseIntPipe) addressId: number, 48 | ): Promise> { 49 | const request: GetAddressRequest = { 50 | address_id: addressId, 51 | contact_id: contactId, 52 | }; 53 | const result = await this.addressService.get(user, request); 54 | return { 55 | data: result, 56 | }; 57 | } 58 | 59 | @Put('/:addressId') 60 | @HttpCode(200) 61 | async update( 62 | @Auth() user: User, 63 | @Param('contactId', ParseIntPipe) contactId: number, 64 | @Param('addressId', ParseIntPipe) addressId: number, 65 | @Body() request: UpdateAddressRequest, 66 | ): Promise> { 67 | request.contact_id = contactId; 68 | request.id = addressId; 69 | const result = await this.addressService.update(user, request); 70 | return { 71 | data: result, 72 | }; 73 | } 74 | 75 | @Delete('/:addressId') 76 | @HttpCode(200) 77 | async remove( 78 | @Auth() user: User, 79 | @Param('contactId', ParseIntPipe) contactId: number, 80 | @Param('addressId', ParseIntPipe) addressId: number, 81 | ): Promise> { 82 | const request: RemoveAddressRequest = { 83 | address_id: addressId, 84 | contact_id: contactId, 85 | }; 86 | await this.addressService.remove(user, request); 87 | return { 88 | data: true, 89 | }; 90 | } 91 | 92 | @Get() 93 | @HttpCode(200) 94 | async list( 95 | @Auth() user: User, 96 | @Param('contactId', ParseIntPipe) contactId: number, 97 | ): Promise> { 98 | const result = await this.addressService.list(user, contactId); 99 | return { 100 | data: result, 101 | }; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/address/address.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AddressService } from './address.service'; 3 | import { AddressController } from './address.controller'; 4 | import { ContactModule } from '../contact/contact.module'; 5 | 6 | @Module({ 7 | imports: [ContactModule], 8 | providers: [AddressService], 9 | controllers: [AddressController], 10 | }) 11 | export class AddressModule {} 12 | -------------------------------------------------------------------------------- /src/address/address.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Inject, Injectable } from '@nestjs/common'; 2 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 3 | import { Logger } from 'winston'; 4 | import { PrismaService } from '../common/prisma.service'; 5 | import { ValidationService } from '../common/validation.service'; 6 | import { Address, User } from '@prisma/client'; 7 | import { 8 | AddressResponse, 9 | CreateAddressRequest, 10 | GetAddressRequest, 11 | RemoveAddressRequest, 12 | UpdateAddressRequest, 13 | } from '../model/address.model'; 14 | import { AddressValidation } from './address.validation'; 15 | import { ContactService } from '../contact/contact.service'; 16 | 17 | @Injectable() 18 | export class AddressService { 19 | constructor( 20 | @Inject(WINSTON_MODULE_PROVIDER) private logger: Logger, 21 | private prismaService: PrismaService, 22 | private validationService: ValidationService, 23 | private contactService: ContactService, 24 | ) {} 25 | 26 | async create( 27 | user: User, 28 | request: CreateAddressRequest, 29 | ): Promise { 30 | this.logger.debug( 31 | `AddressService.create(${JSON.stringify(user)}, ${JSON.stringify(request)})`, 32 | ); 33 | const createRequest: CreateAddressRequest = this.validationService.validate( 34 | AddressValidation.CREATE, 35 | request, 36 | ); 37 | 38 | await this.contactService.checkContactMustExists( 39 | user.username, 40 | createRequest.contact_id, 41 | ); 42 | 43 | const address = await this.prismaService.address.create({ 44 | data: createRequest, 45 | }); 46 | 47 | return this.toAddressResponse(address); 48 | } 49 | 50 | toAddressResponse(address: Address): AddressResponse { 51 | return { 52 | id: address.id, 53 | street: address.street, 54 | city: address.city, 55 | province: address.province, 56 | country: address.country, 57 | postal_code: address.postal_code, 58 | }; 59 | } 60 | 61 | async checkAddressMustExists( 62 | contactId: number, 63 | addressId: number, 64 | ): Promise
{ 65 | const address = await this.prismaService.address.findFirst({ 66 | where: { 67 | id: addressId, 68 | contact_id: contactId, 69 | }, 70 | }); 71 | 72 | if (!address) { 73 | throw new HttpException('Address is not found', 404); 74 | } 75 | 76 | return address; 77 | } 78 | 79 | async get(user: User, request: GetAddressRequest): Promise { 80 | const getRequest: GetAddressRequest = this.validationService.validate( 81 | AddressValidation.GET, 82 | request, 83 | ); 84 | 85 | await this.contactService.checkContactMustExists( 86 | user.username, 87 | getRequest.contact_id, 88 | ); 89 | 90 | const address = await this.checkAddressMustExists( 91 | getRequest.contact_id, 92 | getRequest.address_id, 93 | ); 94 | 95 | return this.toAddressResponse(address); 96 | } 97 | 98 | async update( 99 | user: User, 100 | request: UpdateAddressRequest, 101 | ): Promise { 102 | const updateRequest: UpdateAddressRequest = this.validationService.validate( 103 | AddressValidation.UPDATE, 104 | request, 105 | ); 106 | 107 | await this.contactService.checkContactMustExists( 108 | user.username, 109 | updateRequest.contact_id, 110 | ); 111 | 112 | let address = await this.checkAddressMustExists( 113 | updateRequest.contact_id, 114 | updateRequest.id, 115 | ); 116 | 117 | address = await this.prismaService.address.update({ 118 | where: { 119 | id: address.id, 120 | contact_id: address.contact_id, 121 | }, 122 | data: updateRequest, 123 | }); 124 | 125 | return this.toAddressResponse(address); 126 | } 127 | 128 | async remove( 129 | user: User, 130 | request: RemoveAddressRequest, 131 | ): Promise { 132 | const removeRequest: RemoveAddressRequest = this.validationService.validate( 133 | AddressValidation.REMOVE, 134 | request, 135 | ); 136 | 137 | await this.contactService.checkContactMustExists( 138 | user.username, 139 | removeRequest.contact_id, 140 | ); 141 | await this.checkAddressMustExists( 142 | removeRequest.contact_id, 143 | removeRequest.address_id, 144 | ); 145 | 146 | const address = await this.prismaService.address.delete({ 147 | where: { 148 | id: removeRequest.address_id, 149 | contact_id: removeRequest.contact_id, 150 | }, 151 | }); 152 | 153 | return this.toAddressResponse(address); 154 | } 155 | 156 | async list(user: User, contactId: number): Promise { 157 | await this.contactService.checkContactMustExists(user.username, contactId); 158 | const addresses = await this.prismaService.address.findMany({ 159 | where: { 160 | contact_id: contactId, 161 | }, 162 | }); 163 | 164 | return addresses.map((address) => this.toAddressResponse(address)); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/address/address.validation.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from 'zod'; 2 | 3 | export class AddressValidation { 4 | static readonly CREATE: ZodType = z.object({ 5 | contact_id: z.number().min(1).positive(), 6 | street: z.string().min(1).max(255).optional(), 7 | city: z.string().min(1).max(100).optional(), 8 | province: z.string().min(1).max(100).optional(), 9 | country: z.string().min(1).max(100), 10 | postal_code: z.string().min(1).max(10), 11 | }); 12 | 13 | static readonly GET: ZodType = z.object({ 14 | contact_id: z.number().min(1).positive(), 15 | address_id: z.number().min(1).positive(), 16 | }); 17 | 18 | static readonly UPDATE: ZodType = z.object({ 19 | id: z.number().min(1).positive(), 20 | contact_id: z.number().min(1).positive(), 21 | street: z.string().min(1).max(255).optional(), 22 | city: z.string().min(1).max(100).optional(), 23 | province: z.string().min(1).max(100).optional(), 24 | country: z.string().min(1).max(100), 25 | postal_code: z.string().min(1).max(10), 26 | }); 27 | 28 | static readonly REMOVE: ZodType = z.object({ 29 | contact_id: z.number().min(1).positive(), 30 | address_id: z.number().min(1).positive(), 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommonModule } from './common/common.module'; 3 | import { UserModule } from './user/user.module'; 4 | import { ContactModule } from './contact/contact.module'; 5 | import { AddressModule } from './address/address.module'; 6 | 7 | @Module({ 8 | imports: [CommonModule, UserModule, ContactModule, AddressModule], 9 | controllers: [], 10 | providers: [], 11 | }) 12 | export class AppModule {} 13 | -------------------------------------------------------------------------------- /src/common/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | HttpException, 5 | } from '@nestjs/common'; 6 | 7 | export const Auth = createParamDecorator( 8 | (data: unknown, context: ExecutionContext) => { 9 | const request = context.switchToHttp().getRequest(); 10 | const user = request.user; 11 | if (user) { 12 | return user; 13 | } else { 14 | throw new HttpException('Unauthorized', 401); 15 | } 16 | }, 17 | ); 18 | -------------------------------------------------------------------------------- /src/common/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Injectable() 5 | export class AuthMiddleware implements NestMiddleware { 6 | constructor(private prismaService: PrismaService) {} 7 | 8 | async use(req: any, res: any, next: (error?: any) => void) { 9 | const token = req.headers['authorization'] as string; 10 | if (token) { 11 | const user = await this.prismaService.user.findFirst({ 12 | where: { 13 | token: token, 14 | }, 15 | }); 16 | 17 | if (user) { 18 | req.user = user; 19 | } 20 | } 21 | 22 | next(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { WinstonModule } from 'nest-winston'; 3 | import * as winston from 'winston'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { PrismaService } from './prisma.service'; 6 | import { ValidationService } from './validation.service'; 7 | import { APP_FILTER } from '@nestjs/core'; 8 | import { ErrorFilter } from './error.filter'; 9 | import { AuthMiddleware } from './auth.middleware'; 10 | 11 | @Global() 12 | @Module({ 13 | imports: [ 14 | WinstonModule.forRoot({ 15 | level: 'debug', 16 | format: winston.format.json(), 17 | transports: [new winston.transports.Console()], 18 | }), 19 | ConfigModule.forRoot({ 20 | isGlobal: true, 21 | }), 22 | ], 23 | providers: [ 24 | PrismaService, 25 | ValidationService, 26 | { 27 | provide: APP_FILTER, 28 | useClass: ErrorFilter, 29 | }, 30 | ], 31 | exports: [PrismaService, ValidationService], 32 | }) 33 | export class CommonModule implements NestModule { 34 | configure(consumer: MiddlewareConsumer) { 35 | consumer.apply(AuthMiddleware).forRoutes('/api/*'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/common/error.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { ZodError } from 'zod'; 8 | 9 | @Catch(ZodError, HttpException) 10 | export class ErrorFilter implements ExceptionFilter { 11 | catch(exception: any, host: ArgumentsHost) { 12 | const response = host.switchToHttp().getResponse(); 13 | 14 | if (exception instanceof HttpException) { 15 | response.status(exception.getStatus()).json({ 16 | errors: exception.getResponse(), 17 | }); 18 | } else if (exception instanceof ZodError) { 19 | response.status(400).json({ 20 | errors: 'Validation error', 21 | }); 22 | } else { 23 | response.status(500).json({ 24 | errors: exception.message, 25 | }); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/common/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from '@prisma/client'; 2 | import { Logger } from 'winston'; 3 | import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; 4 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 5 | 6 | @Injectable() 7 | export class PrismaService 8 | extends PrismaClient 9 | implements OnModuleInit 10 | { 11 | constructor( 12 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 13 | ) { 14 | super({ 15 | log: [ 16 | { 17 | emit: 'event', 18 | level: 'info', 19 | }, 20 | { 21 | emit: 'event', 22 | level: 'warn', 23 | }, 24 | { 25 | emit: 'event', 26 | level: 'error', 27 | }, 28 | { 29 | emit: 'event', 30 | level: 'query', 31 | }, 32 | ], 33 | }); 34 | } 35 | 36 | onModuleInit() { 37 | this.$on('info', (e) => { 38 | this.logger.info(e); 39 | }); 40 | this.$on('warn', (e) => { 41 | this.logger.warn(e); 42 | }); 43 | this.$on('error', (e) => { 44 | this.logger.error(e); 45 | }); 46 | this.$on('query', (e) => { 47 | this.logger.info(e); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/common/validation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ZodType } from 'zod'; 3 | 4 | @Injectable() 5 | export class ValidationService { 6 | validate(zodType: ZodType, data: T): T { 7 | return zodType.parse(data); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/contact/contact.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Param, 8 | ParseIntPipe, 9 | Post, 10 | Put, 11 | Query, 12 | } from '@nestjs/common'; 13 | import { ContactService } from './contact.service'; 14 | import { Auth } from '../common/auth.decorator'; 15 | import { User } from '@prisma/client'; 16 | import { 17 | ContactResponse, 18 | CreateContactRequest, 19 | SearchContactRequest, 20 | UpdateContactRequest, 21 | } from '../model/contact.model'; 22 | import { WebResponse } from '../model/web.model'; 23 | 24 | @Controller('/api/contacts') 25 | export class ContactController { 26 | constructor(private contactService: ContactService) {} 27 | 28 | @Post() 29 | @HttpCode(200) 30 | async create( 31 | @Auth() user: User, 32 | @Body() request: CreateContactRequest, 33 | ): Promise> { 34 | const result = await this.contactService.create(user, request); 35 | return { 36 | data: result, 37 | }; 38 | } 39 | 40 | @Get('/:contactId') 41 | @HttpCode(200) 42 | async get( 43 | @Auth() user: User, 44 | @Param('contactId', ParseIntPipe) contactId: number, 45 | ): Promise> { 46 | const result = await this.contactService.get(user, contactId); 47 | return { 48 | data: result, 49 | }; 50 | } 51 | 52 | @Put('/:contactId') 53 | @HttpCode(200) 54 | async update( 55 | @Auth() user: User, 56 | @Param('contactId', ParseIntPipe) contactId: number, 57 | @Body() request: UpdateContactRequest, 58 | ): Promise> { 59 | request.id = contactId; 60 | const result = await this.contactService.update(user, request); 61 | return { 62 | data: result, 63 | }; 64 | } 65 | 66 | @Delete('/:contactId') 67 | @HttpCode(200) 68 | async remove( 69 | @Auth() user: User, 70 | @Param('contactId', ParseIntPipe) contactId: number, 71 | ): Promise> { 72 | await this.contactService.remove(user, contactId); 73 | return { 74 | data: true, 75 | }; 76 | } 77 | 78 | @Get() 79 | @HttpCode(200) 80 | async search( 81 | @Auth() user: User, 82 | @Query('name') name?: string, 83 | @Query('email') email?: string, 84 | @Query('phone') phone?: string, 85 | @Query('page', new ParseIntPipe({ optional: true })) page?: number, 86 | @Query('size', new ParseIntPipe({ optional: true })) size?: number, 87 | ): Promise> { 88 | const request: SearchContactRequest = { 89 | name: name, 90 | email: email, 91 | phone: phone, 92 | page: page || 1, 93 | size: size || 10, 94 | }; 95 | return this.contactService.search(user, request); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/contact/contact.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ContactService } from './contact.service'; 3 | import { ContactController } from './contact.controller'; 4 | 5 | @Module({ 6 | providers: [ContactService], 7 | exports: [ContactService], 8 | controllers: [ContactController], 9 | }) 10 | export class ContactModule {} 11 | -------------------------------------------------------------------------------- /src/contact/contact.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Inject, Injectable } from '@nestjs/common'; 2 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 3 | import { Logger } from 'winston'; 4 | import { PrismaService } from '../common/prisma.service'; 5 | import { Contact, User } from '@prisma/client'; 6 | import { 7 | ContactResponse, 8 | CreateContactRequest, 9 | SearchContactRequest, 10 | UpdateContactRequest, 11 | } from '../model/contact.model'; 12 | import { ValidationService } from '../common/validation.service'; 13 | import { ContactValidation } from './contact.validation'; 14 | import { WebResponse } from '../model/web.model'; 15 | 16 | @Injectable() 17 | export class ContactService { 18 | constructor( 19 | @Inject(WINSTON_MODULE_PROVIDER) private logger: Logger, 20 | private prismaService: PrismaService, 21 | private validationService: ValidationService, 22 | ) {} 23 | 24 | async create( 25 | user: User, 26 | request: CreateContactRequest, 27 | ): Promise { 28 | this.logger.debug( 29 | `ContactService.create(${JSON.stringify(user)}, ${JSON.stringify(request)})`, 30 | ); 31 | const createRequest: CreateContactRequest = this.validationService.validate( 32 | ContactValidation.CREATE, 33 | request, 34 | ); 35 | 36 | const contact = await this.prismaService.contact.create({ 37 | data: { 38 | ...createRequest, 39 | ...{ username: user.username }, 40 | }, 41 | }); 42 | 43 | return this.toContactResponse(contact); 44 | } 45 | 46 | toContactResponse(contact: Contact): ContactResponse { 47 | return { 48 | first_name: contact.first_name, 49 | last_name: contact.last_name, 50 | email: contact.email, 51 | phone: contact.phone, 52 | id: contact.id, 53 | }; 54 | } 55 | 56 | async checkContactMustExists( 57 | username: string, 58 | contactId: number, 59 | ): Promise { 60 | const contact = await this.prismaService.contact.findFirst({ 61 | where: { 62 | username: username, 63 | id: contactId, 64 | }, 65 | }); 66 | 67 | if (!contact) { 68 | throw new HttpException('Contact is not found', 404); 69 | } 70 | 71 | return contact; 72 | } 73 | 74 | async get(user: User, contactId: number): Promise { 75 | const contact = await this.checkContactMustExists(user.username, contactId); 76 | return this.toContactResponse(contact); 77 | } 78 | 79 | async update( 80 | user: User, 81 | request: UpdateContactRequest, 82 | ): Promise { 83 | const updateRequest = this.validationService.validate( 84 | ContactValidation.UPDATE, 85 | request, 86 | ); 87 | let contact = await this.checkContactMustExists( 88 | user.username, 89 | updateRequest.id, 90 | ); 91 | 92 | contact = await this.prismaService.contact.update({ 93 | where: { 94 | id: contact.id, 95 | username: contact.username, 96 | }, 97 | data: updateRequest, 98 | }); 99 | 100 | return this.toContactResponse(contact); 101 | } 102 | 103 | async remove(user: User, contactId: number): Promise { 104 | await this.checkContactMustExists(user.username, contactId); 105 | 106 | const contact = await this.prismaService.contact.delete({ 107 | where: { 108 | id: contactId, 109 | username: user.username, 110 | }, 111 | }); 112 | 113 | return this.toContactResponse(contact); 114 | } 115 | 116 | async search( 117 | user: User, 118 | request: SearchContactRequest, 119 | ): Promise> { 120 | const searchRequest: SearchContactRequest = this.validationService.validate( 121 | ContactValidation.SEARCH, 122 | request, 123 | ); 124 | 125 | const filters = []; 126 | 127 | if (searchRequest.name) { 128 | // add name filter 129 | filters.push({ 130 | OR: [ 131 | { 132 | first_name: { 133 | contains: searchRequest.name, 134 | }, 135 | }, 136 | { 137 | last_name: { 138 | contains: searchRequest.name, 139 | }, 140 | }, 141 | ], 142 | }); 143 | } 144 | 145 | if (searchRequest.email) { 146 | // add email filter 147 | filters.push({ 148 | email: { 149 | contains: searchRequest.email, 150 | }, 151 | }); 152 | } 153 | 154 | if (searchRequest.phone) { 155 | // add phone filter 156 | filters.push({ 157 | phone: { 158 | contains: searchRequest.phone, 159 | }, 160 | }); 161 | } 162 | 163 | const skip = (searchRequest.page - 1) * searchRequest.size; 164 | 165 | const contacts = await this.prismaService.contact.findMany({ 166 | where: { 167 | username: user.username, 168 | AND: filters, 169 | }, 170 | take: searchRequest.size, 171 | skip: skip, 172 | }); 173 | 174 | const total = await this.prismaService.contact.count({ 175 | where: { 176 | username: user.username, 177 | AND: filters, 178 | }, 179 | }); 180 | 181 | return { 182 | data: contacts.map((contact) => this.toContactResponse(contact)), 183 | paging: { 184 | current_page: searchRequest.page, 185 | size: searchRequest.size, 186 | total_page: Math.ceil(total / searchRequest.size), 187 | }, 188 | }; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/contact/contact.validation.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from 'zod'; 2 | 3 | export class ContactValidation { 4 | static readonly CREATE: ZodType = z.object({ 5 | first_name: z.string().min(1).max(100), 6 | last_name: z.string().min(1).max(100).optional(), 7 | email: z.string().min(1).max(100).email().optional(), 8 | phone: z.string().min(1).max(20).optional(), 9 | }); 10 | 11 | static readonly UPDATE: ZodType = z.object({ 12 | id: z.number().positive(), 13 | first_name: z.string().min(1).max(100), 14 | last_name: z.string().min(1).max(100).optional(), 15 | email: z.string().min(1).max(100).email().optional(), 16 | phone: z.string().min(1).max(20).optional(), 17 | }); 18 | 19 | static readonly SEARCH: ZodType = z.object({ 20 | name: z.string().min(1).optional(), 21 | email: z.string().min(1).optional(), 22 | phone: z.string().min(1).optional(), 23 | page: z.number().min(1).positive(), 24 | size: z.number().min(1).max(100).positive(), 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | const logger = app.get(WINSTON_MODULE_NEST_PROVIDER); 9 | app.useLogger(logger); 10 | 11 | await app.listen(3000); 12 | } 13 | bootstrap(); 14 | -------------------------------------------------------------------------------- /src/model/address.model.ts: -------------------------------------------------------------------------------- 1 | export class AddressResponse { 2 | id: number; 3 | street?: string; 4 | city?: string; 5 | province?: string; 6 | country: string; 7 | postal_code: string; 8 | } 9 | 10 | export class CreateAddressRequest { 11 | contact_id: number; 12 | street?: string; 13 | city?: string; 14 | province?: string; 15 | country: string; 16 | postal_code: string; 17 | } 18 | 19 | export class GetAddressRequest { 20 | contact_id: number; 21 | address_id: number; 22 | } 23 | 24 | export class UpdateAddressRequest { 25 | id: number; 26 | contact_id: number; 27 | street?: string; 28 | city?: string; 29 | province?: string; 30 | country: string; 31 | postal_code: string; 32 | } 33 | 34 | export class RemoveAddressRequest { 35 | contact_id: number; 36 | address_id: number; 37 | } 38 | -------------------------------------------------------------------------------- /src/model/contact.model.ts: -------------------------------------------------------------------------------- 1 | export class ContactResponse { 2 | id: number; 3 | first_name: string; 4 | last_name?: string; 5 | email?: string; 6 | phone?: string; 7 | } 8 | 9 | export class CreateContactRequest { 10 | first_name: string; 11 | last_name?: string; 12 | email?: string; 13 | phone?: string; 14 | } 15 | 16 | export class UpdateContactRequest { 17 | id: number; 18 | first_name: string; 19 | last_name?: string; 20 | email?: string; 21 | phone?: string; 22 | } 23 | 24 | export class SearchContactRequest { 25 | name?: string; 26 | email?: string; 27 | phone?: string; 28 | page: number; 29 | size: number; 30 | } 31 | -------------------------------------------------------------------------------- /src/model/user.model.ts: -------------------------------------------------------------------------------- 1 | export class RegisterUserRequest { 2 | username: string; 3 | password: string; 4 | name: string; 5 | } 6 | 7 | export class UserResponse { 8 | username: string; 9 | name: string; 10 | token?: string; 11 | } 12 | 13 | export class LoginUserRequest { 14 | username: string; 15 | password: string; 16 | } 17 | 18 | export class UpdateUserRequest { 19 | name?: string; 20 | password?: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/model/web.model.ts: -------------------------------------------------------------------------------- 1 | export class WebResponse { 2 | data?: T; 3 | errors?: string; 4 | paging?: Paging; 5 | } 6 | 7 | export class Paging { 8 | size: number; 9 | total_page: number; 10 | current_page: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Patch, 8 | Post, 9 | } from '@nestjs/common'; 10 | import { UserService } from './user.service'; 11 | import { WebResponse } from '../model/web.model'; 12 | import { 13 | LoginUserRequest, 14 | RegisterUserRequest, 15 | UpdateUserRequest, 16 | UserResponse, 17 | } from '../model/user.model'; 18 | import { Auth } from '../common/auth.decorator'; 19 | import { User } from '@prisma/client'; 20 | 21 | @Controller('/api/users') 22 | export class UserController { 23 | constructor(private userService: UserService) {} 24 | 25 | @Post() 26 | @HttpCode(200) 27 | async register( 28 | @Body() request: RegisterUserRequest, 29 | ): Promise> { 30 | const result = await this.userService.register(request); 31 | return { 32 | data: result, 33 | }; 34 | } 35 | 36 | @Post('/login') 37 | @HttpCode(200) 38 | async login( 39 | @Body() request: LoginUserRequest, 40 | ): Promise> { 41 | const result = await this.userService.login(request); 42 | return { 43 | data: result, 44 | }; 45 | } 46 | 47 | @Get('/current') 48 | @HttpCode(200) 49 | async get(@Auth() user: User): Promise> { 50 | const result = await this.userService.get(user); 51 | return { 52 | data: result, 53 | }; 54 | } 55 | 56 | @Patch('/current') 57 | @HttpCode(200) 58 | async update( 59 | @Auth() user: User, 60 | @Body() request: UpdateUserRequest, 61 | ): Promise> { 62 | const result = await this.userService.update(user, request); 63 | return { 64 | data: result, 65 | }; 66 | } 67 | 68 | @Delete('/current') 69 | @HttpCode(200) 70 | async logout(@Auth() user: User): Promise> { 71 | await this.userService.logout(user); 72 | return { 73 | data: true, 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | 5 | @Module({ 6 | providers: [UserService], 7 | controllers: [UserController], 8 | }) 9 | export class UserModule {} 10 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Inject, Injectable } from '@nestjs/common'; 2 | import { 3 | LoginUserRequest, 4 | RegisterUserRequest, 5 | UpdateUserRequest, 6 | UserResponse, 7 | } from '../model/user.model'; 8 | import { ValidationService } from '../common/validation.service'; 9 | import { Logger } from 'winston'; 10 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 11 | import { PrismaService } from '../common/prisma.service'; 12 | import { UserValidation } from './user.validation'; 13 | import * as bcrypt from 'bcrypt'; 14 | import { v4 as uuid } from 'uuid'; 15 | import { User } from '@prisma/client'; 16 | 17 | @Injectable() 18 | export class UserService { 19 | constructor( 20 | private validationService: ValidationService, 21 | @Inject(WINSTON_MODULE_PROVIDER) private logger: Logger, 22 | private prismaService: PrismaService, 23 | ) {} 24 | 25 | async register(request: RegisterUserRequest): Promise { 26 | this.logger.debug(`Register new user ${JSON.stringify(request)}`); 27 | const registerRequest: RegisterUserRequest = 28 | this.validationService.validate(UserValidation.REGISTER, request); 29 | 30 | const totalUserWithSameUsername = await this.prismaService.user.count({ 31 | where: { 32 | username: registerRequest.username, 33 | }, 34 | }); 35 | 36 | if (totalUserWithSameUsername != 0) { 37 | throw new HttpException('Username already exists', 400); 38 | } 39 | 40 | registerRequest.password = await bcrypt.hash(registerRequest.password, 10); 41 | 42 | const user = await this.prismaService.user.create({ 43 | data: registerRequest, 44 | }); 45 | 46 | return { 47 | username: user.username, 48 | name: user.name, 49 | }; 50 | } 51 | 52 | async login(request: LoginUserRequest): Promise { 53 | this.logger.debug(`UserService.login(${JSON.stringify(request)})`); 54 | const loginRequest: LoginUserRequest = this.validationService.validate( 55 | UserValidation.LOGIN, 56 | request, 57 | ); 58 | 59 | let user = await this.prismaService.user.findUnique({ 60 | where: { 61 | username: loginRequest.username, 62 | }, 63 | }); 64 | 65 | if (!user) { 66 | throw new HttpException('Username or password is invalid', 401); 67 | } 68 | 69 | const isPasswordValid = await bcrypt.compare( 70 | loginRequest.password, 71 | user.password, 72 | ); 73 | 74 | if (!isPasswordValid) { 75 | throw new HttpException('Username or password is invalid', 401); 76 | } 77 | 78 | user = await this.prismaService.user.update({ 79 | where: { 80 | username: loginRequest.username, 81 | }, 82 | data: { 83 | token: uuid(), 84 | }, 85 | }); 86 | 87 | return { 88 | username: user.username, 89 | name: user.name, 90 | token: user.token, 91 | }; 92 | } 93 | 94 | async get(user: User): Promise { 95 | return { 96 | username: user.username, 97 | name: user.name, 98 | }; 99 | } 100 | 101 | async update(user: User, request: UpdateUserRequest): Promise { 102 | this.logger.debug( 103 | `UserService.update( ${JSON.stringify(user)} , ${JSON.stringify(request)} )`, 104 | ); 105 | 106 | const updateRequest: UpdateUserRequest = this.validationService.validate( 107 | UserValidation.UPDATE, 108 | request, 109 | ); 110 | 111 | if (updateRequest.name) { 112 | user.name = updateRequest.name; 113 | } 114 | 115 | if (updateRequest.password) { 116 | user.password = await bcrypt.hash(updateRequest.password, 10); 117 | } 118 | 119 | const result = await this.prismaService.user.update({ 120 | where: { 121 | username: user.username, 122 | }, 123 | data: user, 124 | }); 125 | 126 | return { 127 | name: result.name, 128 | username: result.username, 129 | }; 130 | } 131 | 132 | async logout(user: User): Promise { 133 | const result = await this.prismaService.user.update({ 134 | where: { 135 | username: user.username, 136 | }, 137 | data: { 138 | token: null, 139 | }, 140 | }); 141 | 142 | return { 143 | username: result.username, 144 | name: result.name, 145 | }; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/user/user.validation.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from 'zod'; 2 | 3 | export class UserValidation { 4 | static readonly REGISTER: ZodType = z.object({ 5 | username: z.string().min(1).max(100), 6 | password: z.string().min(1).max(100), 7 | name: z.string().min(1).max(100), 8 | }); 9 | 10 | static readonly LOGIN: ZodType = z.object({ 11 | username: z.string().min(1).max(100), 12 | password: z.string().min(1).max(100), 13 | }); 14 | 15 | static readonly UPDATE: ZodType = z.object({ 16 | name: z.string().min(1).max(100).optional(), 17 | password: z.string().min(1).max(100).optional(), 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /test/address.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { Logger } from 'winston'; 6 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 7 | import { TestService } from './test.service'; 8 | import { TestModule } from './test.module'; 9 | 10 | describe('AddressController', () => { 11 | let app: INestApplication; 12 | let logger: Logger; 13 | let testService: TestService; 14 | 15 | beforeEach(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [AppModule, TestModule], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | await app.init(); 22 | 23 | logger = app.get(WINSTON_MODULE_PROVIDER); 24 | testService = app.get(TestService); 25 | }); 26 | 27 | describe('POST /api/contacts/:contactId/addresses', () => { 28 | beforeEach(async () => { 29 | await testService.deleteAll(); 30 | 31 | await testService.createUser(); 32 | await testService.createContact(); 33 | }); 34 | 35 | it('should be rejected if request is invalid', async () => { 36 | const contact = await testService.getContact(); 37 | const response = await request(app.getHttpServer()) 38 | .post(`/api/contacts/${contact.id}/addresses`) 39 | .set('Authorization', 'test') 40 | .send({ 41 | street: '', 42 | city: '', 43 | province: '', 44 | country: '', 45 | postal_code: '', 46 | }); 47 | 48 | logger.info(response.body); 49 | 50 | expect(response.status).toBe(400); 51 | expect(response.body.errors).toBeDefined(); 52 | }); 53 | 54 | it('should be able to create address', async () => { 55 | const contact = await testService.getContact(); 56 | const response = await request(app.getHttpServer()) 57 | .post(`/api/contacts/${contact.id}/addresses`) 58 | .set('Authorization', 'test') 59 | .send({ 60 | street: 'jalan test', 61 | city: 'kota test', 62 | province: 'provinsi test', 63 | country: 'negara test', 64 | postal_code: '1111', 65 | }); 66 | 67 | logger.info(response.body); 68 | 69 | expect(response.status).toBe(200); 70 | expect(response.body.data.id).toBeDefined(); 71 | expect(response.body.data.street).toBe('jalan test'); 72 | expect(response.body.data.city).toBe('kota test'); 73 | expect(response.body.data.province).toBe('provinsi test'); 74 | expect(response.body.data.country).toBe('negara test'); 75 | expect(response.body.data.postal_code).toBe('1111'); 76 | }); 77 | }); 78 | 79 | describe('GET /api/contacts/:contactId/addresses/:addressId', () => { 80 | beforeEach(async () => { 81 | await testService.deleteAll(); 82 | 83 | await testService.createUser(); 84 | await testService.createContact(); 85 | await testService.createAddress(); 86 | }); 87 | 88 | it('should be rejected if contact is not found', async () => { 89 | const contact = await testService.getContact(); 90 | const address = await testService.getAddress(); 91 | const response = await request(app.getHttpServer()) 92 | .get(`/api/contacts/${contact.id + 1}/addresses/${address.id}`) 93 | .set('Authorization', 'test'); 94 | 95 | logger.info(response.body); 96 | 97 | expect(response.status).toBe(404); 98 | expect(response.body.errors).toBeDefined(); 99 | }); 100 | 101 | it('should be rejected if address is not found', async () => { 102 | const contact = await testService.getContact(); 103 | const address = await testService.getAddress(); 104 | const response = await request(app.getHttpServer()) 105 | .get(`/api/contacts/${contact.id}/addresses/${address.id + 1}`) 106 | .set('Authorization', 'test'); 107 | 108 | logger.info(response.body); 109 | 110 | expect(response.status).toBe(404); 111 | expect(response.body.errors).toBeDefined(); 112 | }); 113 | 114 | it('should be able to get address', async () => { 115 | const contact = await testService.getContact(); 116 | const address = await testService.getAddress(); 117 | const response = await request(app.getHttpServer()) 118 | .get(`/api/contacts/${contact.id}/addresses/${address.id}`) 119 | .set('Authorization', 'test'); 120 | 121 | logger.info(response.body); 122 | 123 | expect(response.status).toBe(200); 124 | expect(response.body.data.id).toBeDefined(); 125 | expect(response.body.data.street).toBe('jalan test'); 126 | expect(response.body.data.city).toBe('kota test'); 127 | expect(response.body.data.province).toBe('provinsi test'); 128 | expect(response.body.data.country).toBe('negara test'); 129 | expect(response.body.data.postal_code).toBe('1111'); 130 | }); 131 | }); 132 | 133 | describe('PUT /api/contacts/:contactId/addresses/:addressId', () => { 134 | beforeEach(async () => { 135 | await testService.deleteAll(); 136 | 137 | await testService.createUser(); 138 | await testService.createContact(); 139 | await testService.createAddress(); 140 | }); 141 | 142 | it('should be rejected if request is invalid', async () => { 143 | const contact = await testService.getContact(); 144 | const address = await testService.getAddress(); 145 | const response = await request(app.getHttpServer()) 146 | .put(`/api/contacts/${contact.id}/addresses/${address.id}`) 147 | .set('Authorization', 'test') 148 | .send({ 149 | street: '', 150 | city: '', 151 | province: '', 152 | country: '', 153 | postal_code: '', 154 | }); 155 | 156 | logger.info(response.body); 157 | 158 | expect(response.status).toBe(400); 159 | expect(response.body.errors).toBeDefined(); 160 | }); 161 | 162 | it('should be able to update address', async () => { 163 | const contact = await testService.getContact(); 164 | const address = await testService.getAddress(); 165 | const response = await request(app.getHttpServer()) 166 | .put(`/api/contacts/${contact.id}/addresses/${address.id}`) 167 | .set('Authorization', 'test') 168 | .send({ 169 | street: 'jalan test', 170 | city: 'kota test', 171 | province: 'provinsi test', 172 | country: 'negara test', 173 | postal_code: '1111', 174 | }); 175 | 176 | logger.info(response.body); 177 | 178 | expect(response.status).toBe(200); 179 | expect(response.body.data.id).toBeDefined(); 180 | expect(response.body.data.street).toBe('jalan test'); 181 | expect(response.body.data.city).toBe('kota test'); 182 | expect(response.body.data.province).toBe('provinsi test'); 183 | expect(response.body.data.country).toBe('negara test'); 184 | expect(response.body.data.postal_code).toBe('1111'); 185 | }); 186 | 187 | it('should be rejected if contact is not found', async () => { 188 | const contact = await testService.getContact(); 189 | const address = await testService.getAddress(); 190 | const response = await request(app.getHttpServer()) 191 | .put(`/api/contacts/${contact.id + 1}/addresses/${address.id}`) 192 | .set('Authorization', 'test') 193 | .send({ 194 | street: 'jalan test', 195 | city: 'kota test', 196 | province: 'provinsi test', 197 | country: 'negara test', 198 | postal_code: '1111', 199 | }); 200 | 201 | logger.info(response.body); 202 | 203 | expect(response.status).toBe(404); 204 | expect(response.body.errors).toBeDefined(); 205 | }); 206 | 207 | it('should be rejected if address is not found', async () => { 208 | const contact = await testService.getContact(); 209 | const address = await testService.getAddress(); 210 | const response = await request(app.getHttpServer()) 211 | .put(`/api/contacts/${contact.id}/addresses/${address.id + 1}`) 212 | .set('Authorization', 'test') 213 | .send({ 214 | street: 'jalan test', 215 | city: 'kota test', 216 | province: 'provinsi test', 217 | country: 'negara test', 218 | postal_code: '1111', 219 | }); 220 | 221 | logger.info(response.body); 222 | 223 | expect(response.status).toBe(404); 224 | expect(response.body.errors).toBeDefined(); 225 | }); 226 | }); 227 | 228 | describe('DELETE /api/contacts/:contactId/addresses/:addressId', () => { 229 | beforeEach(async () => { 230 | await testService.deleteAll(); 231 | 232 | await testService.createUser(); 233 | await testService.createContact(); 234 | await testService.createAddress(); 235 | }); 236 | 237 | it('should be rejected if contact is not found', async () => { 238 | const contact = await testService.getContact(); 239 | const address = await testService.getAddress(); 240 | const response = await request(app.getHttpServer()) 241 | .delete(`/api/contacts/${contact.id + 1}/addresses/${address.id}`) 242 | .set('Authorization', 'test'); 243 | 244 | logger.info(response.body); 245 | 246 | expect(response.status).toBe(404); 247 | expect(response.body.errors).toBeDefined(); 248 | }); 249 | 250 | it('should be rejected if address is not found', async () => { 251 | const contact = await testService.getContact(); 252 | const address = await testService.getAddress(); 253 | const response = await request(app.getHttpServer()) 254 | .delete(`/api/contacts/${contact.id}/addresses/${address.id + 1}`) 255 | .set('Authorization', 'test'); 256 | 257 | logger.info(response.body); 258 | 259 | expect(response.status).toBe(404); 260 | expect(response.body.errors).toBeDefined(); 261 | }); 262 | 263 | it('should be able to delete address', async () => { 264 | const contact = await testService.getContact(); 265 | const address = await testService.getAddress(); 266 | const response = await request(app.getHttpServer()) 267 | .delete(`/api/contacts/${contact.id}/addresses/${address.id}`) 268 | .set('Authorization', 'test'); 269 | 270 | logger.info(response.body); 271 | 272 | expect(response.status).toBe(200); 273 | expect(response.body.data).toBe(true); 274 | 275 | const addressResult = await testService.getAddress(); 276 | expect(addressResult).toBeNull(); 277 | }); 278 | }); 279 | 280 | describe('GET /api/contacts/:contactId/addresses', () => { 281 | beforeEach(async () => { 282 | await testService.deleteAll(); 283 | 284 | await testService.createUser(); 285 | await testService.createContact(); 286 | await testService.createAddress(); 287 | }); 288 | 289 | it('should be rejected if contact is not found', async () => { 290 | const contact = await testService.getContact(); 291 | const response = await request(app.getHttpServer()) 292 | .get(`/api/contacts/${contact.id + 1}/addresses`) 293 | .set('Authorization', 'test'); 294 | 295 | logger.info(response.body); 296 | 297 | expect(response.status).toBe(404); 298 | expect(response.body.errors).toBeDefined(); 299 | }); 300 | 301 | it('should be able to list address', async () => { 302 | const contact = await testService.getContact(); 303 | const response = await request(app.getHttpServer()) 304 | .get(`/api/contacts/${contact.id}/addresses`) 305 | .set('Authorization', 'test'); 306 | 307 | logger.info(response.body); 308 | 309 | expect(response.status).toBe(200); 310 | expect(response.body.data.length).toBe(1); 311 | expect(response.body.data[0].id).toBeDefined(); 312 | expect(response.body.data[0].street).toBe('jalan test'); 313 | expect(response.body.data[0].city).toBe('kota test'); 314 | expect(response.body.data[0].province).toBe('provinsi test'); 315 | expect(response.body.data[0].country).toBe('negara test'); 316 | expect(response.body.data[0].postal_code).toBe('1111'); 317 | }); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /test/contact.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { Logger } from 'winston'; 6 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 7 | import { TestService } from './test.service'; 8 | import { TestModule } from './test.module'; 9 | 10 | describe('UserController', () => { 11 | let app: INestApplication; 12 | let logger: Logger; 13 | let testService: TestService; 14 | 15 | beforeEach(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [AppModule, TestModule], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | await app.init(); 22 | 23 | logger = app.get(WINSTON_MODULE_PROVIDER); 24 | testService = app.get(TestService); 25 | }); 26 | 27 | describe('POST /api/contacts', () => { 28 | beforeEach(async () => { 29 | await testService.deleteAll(); 30 | 31 | await testService.createUser(); 32 | }); 33 | 34 | it('should be rejected if request is invalid', async () => { 35 | const response = await request(app.getHttpServer()) 36 | .post('/api/contacts') 37 | .set('Authorization', 'test') 38 | .send({ 39 | first_name: '', 40 | last_name: '', 41 | email: 'salah', 42 | phone: '', 43 | }); 44 | 45 | logger.info(response.body); 46 | 47 | expect(response.status).toBe(400); 48 | expect(response.body.errors).toBeDefined(); 49 | }); 50 | 51 | it('should be able to create contact', async () => { 52 | const response = await request(app.getHttpServer()) 53 | .post('/api/contacts') 54 | .set('Authorization', 'test') 55 | .send({ 56 | first_name: 'test', 57 | last_name: 'test', 58 | email: 'test@example.com', 59 | phone: '9999', 60 | }); 61 | 62 | logger.info(response.body); 63 | 64 | expect(response.status).toBe(200); 65 | expect(response.body.data.id).toBeDefined(); 66 | expect(response.body.data.first_name).toBe('test'); 67 | expect(response.body.data.last_name).toBe('test'); 68 | expect(response.body.data.email).toBe('test@example.com'); 69 | expect(response.body.data.phone).toBe('9999'); 70 | }); 71 | }); 72 | 73 | describe('GET /api/contacts/:contactId', () => { 74 | beforeEach(async () => { 75 | await testService.deleteAll(); 76 | 77 | await testService.createUser(); 78 | await testService.createContact(); 79 | }); 80 | 81 | it('should be rejected if contact is not found', async () => { 82 | const contact = await testService.getContact(); 83 | const response = await request(app.getHttpServer()) 84 | .get(`/api/contacts/${contact.id + 1}`) 85 | .set('Authorization', 'test'); 86 | 87 | logger.info(response.body); 88 | 89 | expect(response.status).toBe(404); 90 | expect(response.body.errors).toBeDefined(); 91 | }); 92 | 93 | it('should be able to get contact', async () => { 94 | const contact = await testService.getContact(); 95 | const response = await request(app.getHttpServer()) 96 | .get(`/api/contacts/${contact.id}`) 97 | .set('Authorization', 'test'); 98 | 99 | logger.info(response.body); 100 | 101 | expect(response.status).toBe(200); 102 | expect(response.body.data.id).toBeDefined(); 103 | expect(response.body.data.first_name).toBe('test'); 104 | expect(response.body.data.last_name).toBe('test'); 105 | expect(response.body.data.email).toBe('test@example.com'); 106 | expect(response.body.data.phone).toBe('9999'); 107 | }); 108 | }); 109 | 110 | describe('PUT /api/contacts/:contactId', () => { 111 | beforeEach(async () => { 112 | await testService.deleteAll(); 113 | 114 | await testService.createUser(); 115 | await testService.createContact(); 116 | }); 117 | 118 | it('should be rejected if request is invalid', async () => { 119 | const contact = await testService.getContact(); 120 | const response = await request(app.getHttpServer()) 121 | .put(`/api/contacts/${contact.id}`) 122 | .set('Authorization', 'test') 123 | .send({ 124 | first_name: '', 125 | last_name: '', 126 | email: 'salah', 127 | phone: '', 128 | }); 129 | 130 | logger.info(response.body); 131 | 132 | expect(response.status).toBe(400); 133 | expect(response.body.errors).toBeDefined(); 134 | }); 135 | 136 | it('should be rejected if contact is not found', async () => { 137 | const contact = await testService.getContact(); 138 | const response = await request(app.getHttpServer()) 139 | .put(`/api/contacts/${contact.id + 1}`) 140 | .set('Authorization', 'test') 141 | .send({ 142 | first_name: 'test', 143 | last_name: 'test', 144 | email: 'test@example.com', 145 | phone: '9999', 146 | }); 147 | 148 | logger.info(response.body); 149 | 150 | expect(response.status).toBe(404); 151 | expect(response.body.errors).toBeDefined(); 152 | }); 153 | 154 | it('should be able to update contact', async () => { 155 | const contact = await testService.getContact(); 156 | const response = await request(app.getHttpServer()) 157 | .put(`/api/contacts/${contact.id}`) 158 | .set('Authorization', 'test') 159 | .send({ 160 | first_name: 'test updated', 161 | last_name: 'test updated', 162 | email: 'testupdated@example.com', 163 | phone: '8888', 164 | }); 165 | 166 | logger.info(response.body); 167 | 168 | expect(response.status).toBe(200); 169 | expect(response.body.data.id).toBeDefined(); 170 | expect(response.body.data.first_name).toBe('test updated'); 171 | expect(response.body.data.last_name).toBe('test updated'); 172 | expect(response.body.data.email).toBe('testupdated@example.com'); 173 | expect(response.body.data.phone).toBe('8888'); 174 | }); 175 | }); 176 | 177 | describe('DELETE /api/contacts/:contactId', () => { 178 | beforeEach(async () => { 179 | await testService.deleteAll(); 180 | 181 | await testService.createUser(); 182 | await testService.createContact(); 183 | }); 184 | 185 | it('should be rejected if contact is not found', async () => { 186 | const contact = await testService.getContact(); 187 | const response = await request(app.getHttpServer()) 188 | .delete(`/api/contacts/${contact.id + 1}`) 189 | .set('Authorization', 'test'); 190 | 191 | logger.info(response.body); 192 | 193 | expect(response.status).toBe(404); 194 | expect(response.body.errors).toBeDefined(); 195 | }); 196 | 197 | it('should be able to remove contact', async () => { 198 | const contact = await testService.getContact(); 199 | const response = await request(app.getHttpServer()) 200 | .delete(`/api/contacts/${contact.id}`) 201 | .set('Authorization', 'test'); 202 | 203 | logger.info(response.body); 204 | 205 | expect(response.status).toBe(200); 206 | expect(response.body.data).toBe(true); 207 | }); 208 | }); 209 | 210 | describe('GET /api/contacts', () => { 211 | beforeEach(async () => { 212 | await testService.deleteAll(); 213 | 214 | await testService.createUser(); 215 | await testService.createContact(); 216 | }); 217 | 218 | it('should be able to search contacts', async () => { 219 | const response = await request(app.getHttpServer()) 220 | .get(`/api/contacts`) 221 | .set('Authorization', 'test'); 222 | 223 | logger.info(response.body); 224 | 225 | expect(response.status).toBe(200); 226 | expect(response.body.data.length).toBe(1); 227 | }); 228 | 229 | it('should be able to search contacts by name', async () => { 230 | const response = await request(app.getHttpServer()) 231 | .get(`/api/contacts`) 232 | .query({ 233 | name: 'es', 234 | }) 235 | .set('Authorization', 'test'); 236 | 237 | logger.info(response.body); 238 | 239 | expect(response.status).toBe(200); 240 | expect(response.body.data.length).toBe(1); 241 | }); 242 | 243 | it('should be able to search contacts by name not found', async () => { 244 | const response = await request(app.getHttpServer()) 245 | .get(`/api/contacts`) 246 | .query({ 247 | name: 'wrong', 248 | }) 249 | .set('Authorization', 'test'); 250 | 251 | logger.info(response.body); 252 | 253 | expect(response.status).toBe(200); 254 | expect(response.body.data.length).toBe(0); 255 | }); 256 | 257 | it('should be able to search contacts by email', async () => { 258 | const response = await request(app.getHttpServer()) 259 | .get(`/api/contacts`) 260 | .query({ 261 | email: 'es', 262 | }) 263 | .set('Authorization', 'test'); 264 | 265 | logger.info(response.body); 266 | 267 | expect(response.status).toBe(200); 268 | expect(response.body.data.length).toBe(1); 269 | }); 270 | 271 | it('should be able to search contacts by email not found', async () => { 272 | const response = await request(app.getHttpServer()) 273 | .get(`/api/contacts`) 274 | .query({ 275 | email: 'wrong', 276 | }) 277 | .set('Authorization', 'test'); 278 | 279 | logger.info(response.body); 280 | 281 | expect(response.status).toBe(200); 282 | expect(response.body.data.length).toBe(0); 283 | }); 284 | 285 | it('should be able to search contacts by phone', async () => { 286 | const response = await request(app.getHttpServer()) 287 | .get(`/api/contacts`) 288 | .query({ 289 | phone: '99', 290 | }) 291 | .set('Authorization', 'test'); 292 | 293 | logger.info(response.body); 294 | 295 | expect(response.status).toBe(200); 296 | expect(response.body.data.length).toBe(1); 297 | }); 298 | 299 | it('should be able to search contacts by phone not found', async () => { 300 | const response = await request(app.getHttpServer()) 301 | .get(`/api/contacts`) 302 | .query({ 303 | phone: '88', 304 | }) 305 | .set('Authorization', 'test'); 306 | 307 | logger.info(response.body); 308 | 309 | expect(response.status).toBe(200); 310 | expect(response.body.data.length).toBe(0); 311 | }); 312 | 313 | it('should be able to search contacts with page', async () => { 314 | const response = await request(app.getHttpServer()) 315 | .get(`/api/contacts`) 316 | .query({ 317 | size: 1, 318 | page: 2, 319 | }) 320 | .set('Authorization', 'test'); 321 | 322 | logger.info(response.body); 323 | 324 | expect(response.status).toBe(200); 325 | expect(response.body.data.length).toBe(0); 326 | expect(response.body.paging.current_page).toBe(2); 327 | expect(response.body.paging.total_page).toBe(1); 328 | expect(response.body.paging.size).toBe(1); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /test/test.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TestService } from './test.service'; 3 | 4 | @Module({ 5 | providers: [TestService], 6 | }) 7 | export class TestModule {} 8 | -------------------------------------------------------------------------------- /test/test.service.ts: -------------------------------------------------------------------------------- 1 | import { PrismaService } from '../src/common/prisma.service'; 2 | import { Injectable } from '@nestjs/common'; 3 | import * as bcrypt from 'bcrypt'; 4 | import { Address, Contact, User } from '@prisma/client'; 5 | 6 | @Injectable() 7 | export class TestService { 8 | constructor(private prismaService: PrismaService) {} 9 | 10 | async deleteAll() { 11 | await this.deleteAddress(); 12 | await this.deleteContact(); 13 | await this.deleteUser(); 14 | } 15 | 16 | async deleteUser() { 17 | await this.prismaService.user.deleteMany({ 18 | where: { 19 | username: 'test', 20 | }, 21 | }); 22 | } 23 | 24 | async deleteContact() { 25 | await this.prismaService.contact.deleteMany({ 26 | where: { 27 | username: 'test', 28 | }, 29 | }); 30 | } 31 | 32 | async getUser(): Promise { 33 | return this.prismaService.user.findUnique({ 34 | where: { 35 | username: 'test', 36 | }, 37 | }); 38 | } 39 | 40 | async createUser() { 41 | await this.prismaService.user.create({ 42 | data: { 43 | username: 'test', 44 | name: 'test', 45 | password: await bcrypt.hash('test', 10), 46 | token: 'test', 47 | }, 48 | }); 49 | } 50 | 51 | async getContact(): Promise { 52 | return this.prismaService.contact.findFirst({ 53 | where: { 54 | username: 'test', 55 | }, 56 | }); 57 | } 58 | 59 | async createContact() { 60 | await this.prismaService.contact.create({ 61 | data: { 62 | first_name: 'test', 63 | last_name: 'test', 64 | email: 'test@example.com', 65 | phone: '9999', 66 | username: 'test', 67 | }, 68 | }); 69 | } 70 | 71 | async deleteAddress() { 72 | await this.prismaService.address.deleteMany({ 73 | where: { 74 | contact: { 75 | username: 'test', 76 | }, 77 | }, 78 | }); 79 | } 80 | 81 | async createAddress() { 82 | const contact = await this.getContact(); 83 | await this.prismaService.address.create({ 84 | data: { 85 | contact_id: contact.id, 86 | street: 'jalan test', 87 | city: 'kota test', 88 | province: 'provinsi test', 89 | country: 'negara test', 90 | postal_code: '1111', 91 | }, 92 | }); 93 | } 94 | 95 | async getAddress(): Promise
{ 96 | return this.prismaService.address.findFirst({ 97 | where: { 98 | contact: { 99 | username: 'test', 100 | }, 101 | }, 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/user.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { Logger } from 'winston'; 6 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 7 | import { TestService } from './test.service'; 8 | import { TestModule } from './test.module'; 9 | 10 | describe('UserController', () => { 11 | let app: INestApplication; 12 | let logger: Logger; 13 | let testService: TestService; 14 | 15 | beforeEach(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [AppModule, TestModule], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | await app.init(); 22 | 23 | logger = app.get(WINSTON_MODULE_PROVIDER); 24 | testService = app.get(TestService); 25 | }); 26 | 27 | describe('POST /api/users', () => { 28 | beforeEach(async () => { 29 | await testService.deleteAll(); 30 | }); 31 | 32 | it('should be rejected if request is invalid', async () => { 33 | const response = await request(app.getHttpServer()) 34 | .post('/api/users') 35 | .send({ 36 | username: '', 37 | password: '', 38 | name: '', 39 | }); 40 | 41 | logger.info(response.body); 42 | 43 | expect(response.status).toBe(400); 44 | expect(response.body.errors).toBeDefined(); 45 | }); 46 | 47 | it('should be able to register', async () => { 48 | const response = await request(app.getHttpServer()) 49 | .post('/api/users') 50 | .send({ 51 | username: 'test', 52 | password: 'test', 53 | name: 'test', 54 | }); 55 | 56 | logger.info(response.body); 57 | 58 | expect(response.status).toBe(200); 59 | expect(response.body.data.username).toBe('test'); 60 | expect(response.body.data.name).toBe('test'); 61 | }); 62 | 63 | it('should be rejected if username already exists', async () => { 64 | await testService.createUser(); 65 | const response = await request(app.getHttpServer()) 66 | .post('/api/users') 67 | .send({ 68 | username: 'test', 69 | password: 'test', 70 | name: 'test', 71 | }); 72 | 73 | logger.info(response.body); 74 | 75 | expect(response.status).toBe(400); 76 | expect(response.body.errors).toBeDefined(); 77 | }); 78 | }); 79 | 80 | describe('POST /api/users/login', () => { 81 | beforeEach(async () => { 82 | await testService.deleteAll(); 83 | await testService.createUser(); 84 | }); 85 | 86 | it('should be rejected if request is invalid', async () => { 87 | const response = await request(app.getHttpServer()) 88 | .post('/api/users/login') 89 | .send({ 90 | username: '', 91 | password: '', 92 | }); 93 | 94 | logger.info(response.body); 95 | 96 | expect(response.status).toBe(400); 97 | expect(response.body.errors).toBeDefined(); 98 | }); 99 | 100 | it('should be able to login', async () => { 101 | const response = await request(app.getHttpServer()) 102 | .post('/api/users/login') 103 | .send({ 104 | username: 'test', 105 | password: 'test', 106 | }); 107 | 108 | logger.info(response.body); 109 | 110 | expect(response.status).toBe(200); 111 | expect(response.body.data.username).toBe('test'); 112 | expect(response.body.data.name).toBe('test'); 113 | expect(response.body.data.token).toBeDefined(); 114 | }); 115 | }); 116 | 117 | describe('GET /api/users/current', () => { 118 | beforeEach(async () => { 119 | await testService.deleteAll(); 120 | await testService.createUser(); 121 | }); 122 | 123 | it('should be rejected if token is invalid', async () => { 124 | const response = await request(app.getHttpServer()) 125 | .get('/api/users/current') 126 | .set('Authorization', 'wrong'); 127 | 128 | logger.info(response.body); 129 | 130 | expect(response.status).toBe(401); 131 | expect(response.body.errors).toBeDefined(); 132 | }); 133 | 134 | it('should be able to get user', async () => { 135 | const response = await request(app.getHttpServer()) 136 | .get('/api/users/current') 137 | .set('Authorization', 'test'); 138 | 139 | logger.info(response.body); 140 | 141 | expect(response.status).toBe(200); 142 | expect(response.body.data.username).toBe('test'); 143 | expect(response.body.data.name).toBe('test'); 144 | }); 145 | }); 146 | 147 | describe('PATCH /api/users/current', () => { 148 | beforeEach(async () => { 149 | await testService.deleteAll(); 150 | await testService.createUser(); 151 | }); 152 | 153 | it('should be rejected if request is invalid', async () => { 154 | const response = await request(app.getHttpServer()) 155 | .patch('/api/users/current') 156 | .set('Authorization', 'test') 157 | .send({ 158 | password: '', 159 | name: '', 160 | }); 161 | 162 | logger.info(response.body); 163 | 164 | expect(response.status).toBe(400); 165 | expect(response.body.errors).toBeDefined(); 166 | }); 167 | 168 | it('should be able update name', async () => { 169 | const response = await request(app.getHttpServer()) 170 | .patch('/api/users/current') 171 | .set('Authorization', 'test') 172 | .send({ 173 | name: 'test updated', 174 | }); 175 | 176 | logger.info(response.body); 177 | 178 | expect(response.status).toBe(200); 179 | expect(response.body.data.username).toBe('test'); 180 | expect(response.body.data.name).toBe('test updated'); 181 | }); 182 | 183 | it('should be able update password', async () => { 184 | let response = await request(app.getHttpServer()) 185 | .patch('/api/users/current') 186 | .set('Authorization', 'test') 187 | .send({ 188 | password: 'updated', 189 | }); 190 | 191 | logger.info(response.body); 192 | 193 | expect(response.status).toBe(200); 194 | expect(response.body.data.username).toBe('test'); 195 | expect(response.body.data.name).toBe('test'); 196 | 197 | response = await request(app.getHttpServer()) 198 | .post('/api/users/login') 199 | .send({ 200 | username: 'test', 201 | password: 'updated', 202 | }); 203 | 204 | logger.info(response.body); 205 | 206 | expect(response.status).toBe(200); 207 | expect(response.body.data.token).toBeDefined(); 208 | }); 209 | }); 210 | 211 | describe('DELETE /api/users/current', () => { 212 | beforeEach(async () => { 213 | await testService.deleteAll(); 214 | await testService.createUser(); 215 | }); 216 | 217 | it('should be rejected if token is invalid', async () => { 218 | const response = await request(app.getHttpServer()) 219 | .delete('/api/users/current') 220 | .set('Authorization', 'wrong'); 221 | 222 | logger.info(response.body); 223 | 224 | expect(response.status).toBe(401); 225 | expect(response.body.errors).toBeDefined(); 226 | }); 227 | 228 | it('should be able to logout user', async () => { 229 | const response = await request(app.getHttpServer()) 230 | .delete('/api/users/current') 231 | .set('Authorization', 'test'); 232 | 233 | logger.info(response.body); 234 | 235 | expect(response.status).toBe(200); 236 | expect(response.body.data).toBe(true); 237 | 238 | const user = await testService.getUser(); 239 | expect(user.token).toBeNull(); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------