├── .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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------