diff --git a/.env.example b/.env.example index 0f4a46d..497b15e 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,5 @@ MYSQL_DATABASE= SERVER_PORT= DB_DATABASE= TOKEN_SECRET= +EMAIL= +EMAIL_PASSWORD= \ No newline at end of file diff --git a/.gitignore b/.gitignore index c957b1b..ed92e29 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,5 @@ dist .pnp.* .http -public/img/product-photo \ No newline at end of file +public/img/product-photo +.vercel diff --git a/db/connection.ts b/db/connection.ts deleted file mode 100644 index 392444b..0000000 --- a/db/connection.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createPool } from 'mysql2/promise' -import 'dotenv/config' - -const connection = createPool({ - database: process.env.MYSQL_DATABASE, - user: process.env.DB_USER, - password: process.env.MYSQL_ROOT_PASSWORD, - host: process.env.DB_HOST, - port: 3306, -}) - -export default connection diff --git a/db/init.sql b/db/init.sql deleted file mode 100644 index 77ddf05..0000000 --- a/db/init.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE DATABASE IF NOT EXISTS juadah; -USE juadah; - -CREATE TABLE IF NOT EXISTS users ( - id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, - fullname VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - refresh_token VARCHAR(1000), - - UNIQUE(email) -); - -CREATE TABLE IF NOT EXISTS products ( - id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - price float NOT NULL, - images JSON -); - -CREATE TABLE IF NOT EXISTS ratings ( - id_user INT NOT NULL, - id_product INT NOT NULL, - star TINYINT(1) NOT NULL, - message VARCHAR(500) NOT NULL, - - PRIMARY KEY(id_user, id_product), - FOREIGN KEY(id_user) REFERENCES users(id), - FOREIGN KEY(id_product) REFERENCES products(id) -); diff --git a/package-lock.json b/package-lock.json index 8658b3c..c45be09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,23 @@ "": { "name": "juadah-backend", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { + "@prisma/client": "^5.20.0", "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.20.0", "jsonwebtoken": "^9.0.2", + "midtrans-client": "^1.4.2", "multer": "^1.4.5-lts.1", + "multer-storage-cloudinary": "^4.0.0", "mysql2": "^3.11.2", + "nodemailer": "^6.9.15", + "pino": "^9.5.0", + "pino-http": "^10.3.0", "xss-filters": "^1.2.7", "zod": "^3.23.8" }, @@ -30,10 +37,12 @@ "@types/jsonwebtoken": "^9.0.6", "@types/multer": "^1.4.12", "@types/node": "^22.5.4", + "@types/nodemailer": "^6.4.16", "@types/xss-filters": "^0.0.30", "husky": "^9.1.5", "jest": "^29.7.0", "lint-staged": "^15.2.10", + "prisma": "^5.20.0", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.2" @@ -1329,6 +1338,74 @@ "node": ">=10" } }, + "node_modules/@prisma/client": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.20.0.tgz", + "integrity": "sha512-CLv55ZuMuUawMsxoqxGtLT3bEZoa2W8L3Qnp6rDIFWy+ZBrUcOFKdoeGPSnbBqxc3SkdxJrF+D1veN/WNynZYA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.20.0.tgz", + "integrity": "sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.20.0.tgz", + "integrity": "sha512-DtqkP+hcZvPEbj8t8dK5df2b7d3B8GNauKqaddRRqQBBlgkbdhJkxhoJTrOowlS3vaRt2iMCkU0+CSNn0KhqAQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.20.0", + "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284", + "@prisma/fetch-engine": "5.20.0", + "@prisma/get-platform": "5.20.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284.tgz", + "integrity": "sha512-Lg8AS5lpi0auZe2Mn4gjuCg081UZf88k3cn0RCwHgR+6cyHHpttPZBElJTHf83ZGsRNAmVCZCfUGA57WB4u4JA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.20.0.tgz", + "integrity": "sha512-JVcaPXC940wOGpCOwuqQRTz6I9SaBK0c1BAyC1pcz9xBi+dzFgUu3G/p9GV1FhFs9OKpfSpIhQfUJE9y00zhqw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.20.0", + "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284", + "@prisma/get-platform": "5.20.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.20.0.tgz", + "integrity": "sha512-8/+CehTZZNzJlvuryRgc77hZCWrUDYd/PmlZ7p2yNXtmf2Una4BWnTbak3us6WVdqoz5wmptk6IhsXdG2v5fmA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.20.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1598,6 +1675,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -1885,6 +1972,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -1894,6 +1990,15 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2439,6 +2544,32 @@ "node": ">=12" } }, + "node_modules/cloudinary": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", + "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==", + "license": "MIT", + "peer": true, + "dependencies": { + "cloudinary-core": "^2.13.0", + "core-js": "^3.30.1", + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cloudinary-core": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.13.1.tgz", + "integrity": "sha512-z53GPNWnvU0Zi+ns8CIVbZBfj7ps/++zDvwIyiFuq5p1MoK+KUCg0k5mBceDDHTnx1gHmHUd9aohS+gDxPNt6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "lodash": ">=4.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2595,6 +2726,18 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3069,6 +3212,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3166,6 +3318,26 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3288,7 +3460,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4954,6 +5125,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -5282,6 +5459,16 @@ "node": ">=8.6" } }, + "node_modules/midtrans-client": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/midtrans-client/-/midtrans-client-1.4.2.tgz", + "integrity": "sha512-hGT6UDF6WsmOprJYdgxReT5qxOPj+9VGVbJTe6txYICkadI01yC1ApBlkf+5AH/2v4fWNo03421VVpNfJDFAyg==", + "license": "MIT", + "dependencies": { + "axios": "^0.26.0", + "lodash": "^4.17.21" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -5435,6 +5622,15 @@ "node": ">= 6.0.0" } }, + "node_modules/multer-storage-cloudinary": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", + "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", + "license": "MIT", + "peerDependencies": { + "cloudinary": "^1.21.0" + } + }, "node_modules/mysql2": { "version": "3.11.2", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.2.tgz", @@ -5544,6 +5740,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -5616,6 +5821,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5811,6 +6025,55 @@ "node": ">=0.10" } }, + "node_modules/pino": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", + "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.3.0.tgz", + "integrity": "sha512-kaHQqt1i5S9LXWmyuw6aPPqYW/TjoDPizPs4PnDW4hSpajz2Uo/oisNliLf7We1xzpiLacdntmw8yaZiEkppQQ==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -5862,12 +6125,38 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.20.0.tgz", + "integrity": "sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.20.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -5912,6 +6201,18 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -5927,6 +6228,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5992,6 +6299,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6140,6 +6456,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6390,6 +6715,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6411,6 +6745,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6631,6 +6974,15 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index fef0d71..15e4dfe 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "juadah-backend", "version": "1.0.0", - "main": "index.js", + "main": "dist/index.js", "scripts": { "test": "npx jest", + "postinstall": "prisma generate", + "build": "npx prisma generate && npx tsc", "dev": "npx ts-node-dev --respawn --transpile-only src/index.ts", "lint": "npx biome lint ./src", "fix-lint": "npx biome lint --write --unsafe ./src", "format": "npx biome format --write ./src", - "prepare": "husky" + "prepare": "husky", + "start": "node dist/index.js" }, "lint-staged": { "*": [ @@ -20,14 +23,20 @@ "license": "ISC", "description": "", "dependencies": { + "@prisma/client": "^5.20.0", "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.20.0", "jsonwebtoken": "^9.0.2", + "midtrans-client": "^1.4.2", "multer": "^1.4.5-lts.1", + "multer-storage-cloudinary": "^4.0.0", "mysql2": "^3.11.2", + "nodemailer": "^6.9.15", + "pino": "^9.5.0", + "pino-http": "^10.3.0", "xss-filters": "^1.2.7", "zod": "^3.23.8" }, @@ -41,10 +50,12 @@ "@types/jsonwebtoken": "^9.0.6", "@types/multer": "^1.4.12", "@types/node": "^22.5.4", + "@types/nodemailer": "^6.4.16", "@types/xss-filters": "^0.0.30", "husky": "^9.1.5", "jest": "^29.7.0", "lint-staged": "^15.2.10", + "prisma": "^5.20.0", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.2" diff --git a/prisma/migrations/20241016022512_init/migration.sql b/prisma/migrations/20241016022512_init/migration.sql new file mode 100644 index 0000000..b2a6a6e --- /dev/null +++ b/prisma/migrations/20241016022512_init/migration.sql @@ -0,0 +1,45 @@ +-- CreateEnum +CREATE TYPE "crdb_internal_region" AS ENUM ('gcp-asia-southeast1'); + +-- CreateTable +CREATE TABLE "products" ( + "id" BIGSERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "images" JSONB, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ratings" ( + "id_user" BIGINT NOT NULL, + "id_product" BIGINT NOT NULL, + "star" INTEGER NOT NULL, + "message" TEXT NOT NULL, + + CONSTRAINT "ratings_pkey" PRIMARY KEY ("id_user","id_product") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" BIGSERIAL NOT NULL, + "fullname" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "refresh_token" TEXT, + "email_verified" BOOLEAN DEFAULT false, + "verification_token" CHAR(6), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- AddForeignKey +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_id_product_fkey" FOREIGN KEY ("id_product") REFERENCES "products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_id_user_fkey" FOREIGN KEY ("id_user") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20241115044312_init/migration.sql b/prisma/migrations/20241115044312_init/migration.sql new file mode 100644 index 0000000..f97520e --- /dev/null +++ b/prisma/migrations/20241115044312_init/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..1d32b3e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,51 @@ +generator client { + provider = "prisma-client-js" +binaryTargets = ["native", "rhel-openssl-3.0.x", "debian-openssl-1.1.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} + +model products { + id BigInt @id @default(autoincrement()) + name String + description String + price Float + images Json? + ratings ratings[] +} + +model ratings { + id_user BigInt + id_product BigInt + star Int + message String + products products @relation(fields: [id_product], references: [id], onDelete: NoAction, onUpdate: NoAction) + users users @relation(fields: [id_user], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@id([id_user, id_product]) +} + +model users { + id BigInt @id @default(autoincrement()) + fullname String + email String @unique + password String + refresh_token String? + email_verified Boolean? @default(false) + verification_token String? @db.Char(6) + role Role @default(USER) + ratings ratings[] +} + +enum crdb_internal_region { + gcp_asia_southeast1 @map("gcp-asia-southeast1") +} + +enum Role { + ADMIN + USER +} diff --git a/src/auth/repository.ts b/src/auth/repository.ts index 6662bce..c639d99 100644 --- a/src/auth/repository.ts +++ b/src/auth/repository.ts @@ -1,103 +1,184 @@ -import type { Pool, ResultSetHeader, RowDataPacket } from 'mysql2/promise' +import type { PrismaClient } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AuthCredentialError, + BadRequestError, EmailAlreadyExistsError, + NotFoundError, ServerError, } from '../lib/Error' +import { type Logger, getContext } from '../lib/logger' import type { RegisterUser } from './schema' -type User = { - email: string - fullname: string - id: number - password: string -} - class AuthRepository { - constructor(private db: Pool) {} + constructor( + private prisma: PrismaClient, + private logger: Logger, + ) {} - private USER_ALREADY_EXISTS = 1062 - private ER_BAD_DB_ERROR = 'ER_BAD_DB_ERROR' + private USER_ALREADY_EXISTS = 'P2002' + private RELATED_RECORD_NOT_EXIST = 'P2025' - async createUser(data: RegisterUser) { + async createUser(data: RegisterUser, refreshToken: string, otp: string) { try { - const [rows] = await this.db.execute( - 'INSERT INTO users (fullname, email, password) VALUES (?,?,?)', - [data.fullname, data.email, data.password], - ) - const result = rows as ResultSetHeader - - return { userId: result.insertId } + const newUser = await this.prisma.users.create({ + data: { + email: data.email, + fullname: data.fullname, + password: data.password, + refresh_token: refreshToken, + verification_token: otp, + }, + select: { + id: true, + email: true, + role: true, + fullname: true, + }, + }) + return newUser } catch (error: any) { - if (error.errno === this.USER_ALREADY_EXISTS) { + const context = getContext() + if ( + error instanceof PrismaClientKnownRequestError && + error.code === this.USER_ALREADY_EXISTS + ) { + this.logger('error', error.message, 'repository', 'createUser', context) throw new EmailAlreadyExistsError() } - if (error.code && error.code === this.ER_BAD_DB_ERROR) { - throw new ServerError( - "error while creating the account, this is not your fault, we're working on it. please try again later", - ) - } - throw new Error(error) + this.logger( + 'error', + error.message || error, + 'repository', + 'createUser', + context, + ) + throw new ServerError( + "error while creating the account, this is not your fault, we're working on it. please try again later", + ) } } async getUserByEmail(email: string) { - const [rows] = await this.db.query( - 'SELECT id, email, fullname, password from users WHERE email = ?', - [email], - ) + try { + const user = await this.prisma.users.findUniqueOrThrow({ + where: { + email: email, + }, + select: { + id: true, + email: true, + fullname: true, + role: true, + password: true, + }, + }) - if (!rows.length) { - throw new AuthCredentialError() - } - const result = rows as unknown as User[] + return user + } catch (error: any) { + const context = getContext() + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === this.RELATED_RECORD_NOT_EXIST) { + this.logger( + 'error', + 'email not found', + 'repository', + 'getUserByEmail', + context, + ) + throw new AuthCredentialError() + } + } - return { - id: result[0]?.id, - fullname: result[0]?.fullname, - email: result[0]?.email, - password: result[0]?.password, + this.logger( + 'error', + error.message || error, + 'repository', + 'getUserByEmail', + context, + ) + throw new ServerError( + 'fail to proccess your request, this is not your fault, please try again later', + ) } } - async getUserById( - id: number, - ): Promise<{ id: number; fullname: string; email: string }> { - const [rows] = await this.db.query( - 'SELECT id, fullname, email FROM users WHERE id = ?', - [id], - ) - - if (!rows.length) { - throw new Error('user not found') - } + async saveTokenToDb(token: string, userId: bigint) { + try { + await this.prisma.users.update({ + where: { + id: userId, + }, + data: { + refresh_token: token, + }, + }) - return { - id: rows[0]?.id, - fullname: rows[0]?.fullname, - email: rows[0]?.email, + return { affectedRows: 1 } + } catch (error: any) { + const context = getContext() + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === this.RELATED_RECORD_NOT_EXIST) { + this.logger( + 'error', + `fail to save refresh token to db: ${error.message}`, + 'repository', + 'saveTokenToDb', + context, + ) + throw new BadRequestError('fail to do this action, please try again') + } + } + this.logger( + 'error', + error.message || error, + 'repository', + 'saveTokenToDb', + context, + ) } } - async saveTokenToDb(token: string, userId: number) { - const [rows] = await this.db.execute( - 'UPDATE users SET refresh_token = ? WHERE id = ?', - [token, userId], - ) - const result = rows as ResultSetHeader - return { affectedRows: result.affectedRows } - } + async getTokenByEmail(email: string) { + try { + const refreshToken = await this.prisma.users.findUniqueOrThrow({ + where: { + email: email, + }, + select: { + refresh_token: true, + }, + }) + if (!refreshToken) { + throw new NotFoundError( + 'searching refresh token based on user id is not found', + ) + } - async getTokenByUserId(userId: number) { - const [rows] = await this.db.query( - 'SELECT refresh_token FROM users WHERE id = ?', - [userId], - ) - if (!rows.length) { - throw new Error('token not found in database') + return refreshToken.refresh_token + } catch (error: any) { + const context = getContext() + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === this.RELATED_RECORD_NOT_EXIST) { + this.logger( + 'error', + `get token failed not found ${error.message}`, + 'repository', + 'getTokenByEmail', + context, + ) + throw new NotFoundError('refresh token not found') + } + } + this.logger( + 'error', + error.message || error, + 'repository', + 'getTokenByEmail', + context, + ) + throw new BadRequestError('get refresh token failed, bad request') } - - return rows[0]?.refresh_token as string } } diff --git a/src/auth/service.test.ts b/src/auth/service.test.ts index 38f3f9d..baaa894 100644 --- a/src/auth/service.test.ts +++ b/src/auth/service.test.ts @@ -1,5 +1,6 @@ import { hashSync } from 'bcrypt' import { AuthCredentialError, EmailAlreadyExistsError } from '../lib/Error' +import logger from '../lib/logger' import { createNewToken, refreshTokenMaxAge, verifyToken } from '../lib/token' import type AuthRepository from './repository' import AuthService from './service' @@ -17,20 +18,28 @@ describe('auth service', () => { authRepo = { createUser: jest.fn(), getUserById: jest.fn(), + getTokenByEmail: jest.fn(), getUserByEmail: jest.fn(), saveTokenToDb: jest.fn(), getTokenByUserId: jest.fn(), } as unknown as jest.Mocked - authService = new AuthService(authRepo) + authService = new AuthService(authRepo, logger) }) describe('register user', () => { it('should register new user', async () => { - authRepo.createUser.mockResolvedValue({ userId: 1 }) + authRepo.createUser.mockResolvedValue({ + id: BigInt(1), + role: 'USER', + email: VALID_EMAIL, + fullname: FULLNAME, + }) authRepo.saveTokenToDb.mockResolvedValue({ affectedRows: 1 }) - authRepo.getUserById.mockResolvedValue({ - id: 1, + authRepo.getUserByEmail.mockResolvedValue({ + id: BigInt(1), + role: 'USER', + password: '', fullname: FULLNAME, email: VALID_EMAIL, }) @@ -46,7 +55,12 @@ describe('auth service', () => { expect(newUser.response).toEqual({ status: 'success', data: { - user: { id: 1, email: VALID_EMAIL, fullname: FULLNAME }, + user: { + id: 1n, + email: VALID_EMAIL, + fullname: FULLNAME, + role: 'USER', + }, }, }) expect(newUser.token?.accessToken).not.toBeNull() @@ -84,7 +98,8 @@ describe('auth service', () => { it('should fail caused wrong password', async () => { authRepo.getUserByEmail.mockResolvedValue({ - id: 1, + id: BigInt(1), + role: 'USER', fullname: FULLNAME, email: 'testing@email.com', password: hashSync(VALID_PASSWORD, 10), @@ -105,7 +120,8 @@ describe('auth service', () => { it('should success', async () => { authRepo.getUserByEmail.mockResolvedValue({ - id: 1, + id: BigInt(1), + role: 'USER', fullname: FULLNAME, email: VALID_EMAIL, password: hashSync(VALID_PASSWORD, 10), @@ -122,7 +138,14 @@ describe('auth service', () => { expect(result).toHaveProperty('token') expect(result.response).toEqual({ status: 'success', - data: { user: { id: 1, email: VALID_EMAIL, fullname: FULLNAME } }, + data: { + user: { + id: 1n, + email: VALID_EMAIL, + fullname: FULLNAME, + role: 'USER', + }, + }, }) }) }) @@ -132,10 +155,10 @@ describe('auth service', () => { const validRefreshToken = createNewToken({ email: VALID_EMAIL, fullname: FULLNAME, - userId: 1, + role: 'USER', expiration: refreshTokenMaxAge, }) - authRepo.getTokenByUserId.mockResolvedValue(validRefreshToken) + authRepo.getTokenByEmail.mockResolvedValue(validRefreshToken) const result = await authService.refreshToken(validRefreshToken) expect(result).toHaveProperty('token') if (!result.token) { @@ -143,8 +166,8 @@ describe('auth service', () => { } const { decodedData: accessToken } = verifyToken(result.token) expect(accessToken).toHaveProperty('email') - expect(accessToken).toHaveProperty('userId') - expect(accessToken?.userId).toBe(1) + expect(accessToken).toHaveProperty('role') + expect(accessToken).toHaveProperty('fullname') expect(accessToken?.email).toBe(VALID_EMAIL) }) @@ -161,13 +184,13 @@ describe('auth service', () => { const validRefreshToken = createNewToken({ email: VALID_EMAIL, fullname: FULLNAME, - userId: 1, + role: 'USER', expiration: refreshTokenMaxAge, }) - authRepo.getTokenByUserId.mockRejectedValue('token not found in database') + authRepo.getTokenByEmail.mockRejectedValue('token not found in database') const result = await authService.refreshToken(validRefreshToken) - expect(authRepo.getTokenByUserId).toHaveBeenCalled() + expect(authRepo.getTokenByEmail).toHaveBeenCalled() expect(result).not.toHaveProperty('token') expect(result.response.status).toBe('fail') if (result.response.status === 'fail') { @@ -181,20 +204,20 @@ describe('auth service', () => { const tokenFromDb = createNewToken({ email: VALID_EMAIL, fullname: FULLNAME, - userId: 1, + role: 'USER', expiration: refreshTokenMaxAge, }) - authRepo.getTokenByUserId.mockResolvedValue(tokenFromDb) + authRepo.getTokenByEmail.mockResolvedValue(tokenFromDb) const tokenFromUser = createNewToken({ email: INVALID_EMAIL, fullname: FULLNAME, - userId: 1, + role: 'USER', expiration: refreshTokenMaxAge, }) const result = await authService.refreshToken(tokenFromUser) - expect(authRepo.getTokenByUserId).toHaveBeenCalled() + expect(authRepo.getTokenByEmail).toHaveBeenCalled() expect(result).not.toHaveProperty('token') expect(result.response.status).toBe('fail') if (result.response.status === 'fail') { diff --git a/src/auth/service.ts b/src/auth/service.ts index 59dcb53..9ddcdd5 100644 --- a/src/auth/service.ts +++ b/src/auth/service.ts @@ -1,28 +1,42 @@ import { Auth } from '../lib/Auth' import { AuthCredentialError } from '../lib/Error' +import type { Logger } from '../lib/logger' +import { getContext } from '../lib/logger' import type ApiResponse from '../schema' import type { Login, RegisterUser } from './schema' export interface User { - id: number + id: bigint fullname: string email: string + role: 'ADMIN' | 'USER' password: string } interface AuthRepository { - createUser(data: RegisterUser): Promise<{ userId: number }> + createUser( + data: RegisterUser, + refreshToken: string, + otp: string, + ): Promise<{ + id: bigint + fullname: string + role: 'ADMIN' | 'USER' + email: string + }> getUserByEmail(email: string): Promise> - getUserById(id: number): Promise> saveTokenToDb( token: string, - userId: number, - ): Promise<{ affectedRows: number }> - getTokenByUserId(userId: number): Promise + userId: bigint, + ): Promise<{ affectedRows: number } | undefined> + getTokenByEmail(email: string): Promise } class AuthService extends Auth { - constructor(public repository: AuthRepository) { + constructor( + public repository: AuthRepository, + private logger: Logger, + ) { super() } @@ -31,47 +45,55 @@ class AuthService extends Auth { token?: { accessToken: string; refreshToken: string } }> { try { - const newUser = await this.repository.createUser({ + const accessToken = this.createAccessToken({ fullname: data.fullname, + role: 'USER', email: data.email, - password: await this.hashPassword(data.password), - }) - - const user = await this.repository.getUserById(newUser.userId) - if (!user.email || !user.fullname) { - throw new Error('create user is fail, please try again') - } - - const accessToken = this.createAccessToken({ - fullname: user.fullname, - email: user.email, - userId: newUser.userId, }) const refreshToken = this.createRefreshToken({ - fullname: user.fullname, - email: user.email, - userId: newUser.userId, + fullname: data.fullname, + role: 'USER', + email: data.email, }) - await this.repository.saveTokenToDb(refreshToken, newUser.userId) + const otp = this.generateOTP() + const newUser = await this.repository.createUser( + { + email: data.email, + fullname: data.fullname, + password: await this.hashPassword(data.password), + }, + refreshToken, + otp, + ) + return { response: { status: 'success', data: { user: { - id: newUser.userId, - fullname: user.fullname, - email: user.email, + id: newUser.id, + role: newUser.role, + fullname: newUser.fullname, + email: newUser.email, }, }, }, token: { accessToken, refreshToken }, } } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'registerUser', + context, + ) return { response: { status: 'fail', errors: { - code: typeof error.code === 'number' ? error.code : 400, + code: error.code || 400, message: error.message || error, }, }, @@ -85,7 +107,13 @@ class AuthService extends Auth { }> { try { const user = await this.repository.getUserByEmail(data.email) - if (!user.password || !user.email || !user.id || !user.fullname) { + if ( + !user.password || + !user.email || + !user.id || + !user.role || + !user.fullname + ) { throw new AuthCredentialError() } const isPasswordMatch = await this.verifyPassword( @@ -98,13 +126,13 @@ class AuthService extends Auth { const accessToken = this.createAccessToken({ fullname: user.fullname, + role: user.role, email: user.email, - userId: user.id, }) const refreshToken = this.createRefreshToken({ fullname: user.fullname, + role: user.role, email: user.email, - userId: user.id, }) this.repository.saveTokenToDb(refreshToken, user.id) @@ -114,6 +142,7 @@ class AuthService extends Auth { data: { user: { id: user.id, + role: user.role, fullname: user.fullname, email: user.email, }, @@ -122,6 +151,8 @@ class AuthService extends Auth { token: { accessToken, refreshToken }, } } catch (error: any) { + const context = getContext() + this.logger('error', error.message || error, 'service', 'login', context) if (error.code && error.code === 'ECONNREFUSED') { return { response: { @@ -137,7 +168,7 @@ class AuthService extends Auth { return { response: { status: 'fail', - errors: { code: 400, message: error.message }, + errors: { code: error.code || 400, message: error.message }, }, } } @@ -153,16 +184,16 @@ class AuthService extends Auth { if (!decodedData) { throw new Error('invalid refresh token') } - const tokenFromDb = await this.repository.getTokenByUserId( - decodedData.userId, + const tokenFromDb = await this.repository.getTokenByEmail( + decodedData.email, ) if (token !== tokenFromDb) { throw new Error('invalid refresh token') } const newAccessToken = this.createAccessToken({ fullname: decodedData.fullname, + role: decodedData.role, email: decodedData.email, - userId: decodedData.userId, }) return { response: { @@ -170,6 +201,7 @@ class AuthService extends Auth { data: { user: { id: decodedData.userId, + role: decodedData.role, fullname: decodedData.fullname, email: decodedData.email, }, @@ -178,11 +210,19 @@ class AuthService extends Auth { token: newAccessToken, } } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'refreshToken', + context, + ) return { response: { status: 'fail', errors: { - code: 400, + code: error.code || 400, message: error.message || error, }, }, diff --git a/src/index.ts b/src/index.ts index 22ac0c1..61875c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,24 @@ import cookieParser from 'cookie-parser' import express from 'express' -import routes from './routes' import 'dotenv/config' import cors from 'cors' - +import sanitizeInput from './middlewares/sanitizeInput' const app = express() +import routes from './routes' + const port = process.env.SERVER_PORT -app.use( - cors({ - credentials: true, - origin: ['http://localhost:5173'], - allowedHeaders: ['Content-Type', 'Authorization'], - }), -) +BigInt.prototype.toJSON = function () { + return this.toString() +} + +app.use(cors()) app.use(express.json()) app.use(cookieParser()) - +app.use(sanitizeInput) +app.use('/api/', routes) app.listen(port, () => { - routes(app) console.info(`Server running at port ${port}`) }) + +export default app diff --git a/src/lib/Auth.ts b/src/lib/Auth.ts index d45f509..69aa5f1 100644 --- a/src/lib/Auth.ts +++ b/src/lib/Auth.ts @@ -17,30 +17,39 @@ export class Auth { protected createAccessToken(data: { fullname: string - userId: number email: string + role: 'ADMIN' | 'USER' }) { return createNewToken({ fullname: data.fullname, email: data.email, - userId: data.userId, + role: data.role, expiration: accessTokenMaxAge, }) } protected createRefreshToken(data: { fullname: string + role: 'ADMIN' | 'USER' email: string - userId: number }) { return createNewToken({ fullname: data.fullname, - userId: data.userId, email: data.email, + role: data.role, expiration: refreshTokenMaxAge, }) } + public generateOTP() { + const otp = [] + for (let i = 0; i < 6; i++) { + const randomNumber = Math.round(Math.random() * 9) + otp.push(randomNumber) + } + return otp.join('') + } + protected verifyToken(token: string) { return verifyToken(token) } diff --git a/src/lib/Error.ts b/src/lib/Error.ts index 1996e4e..af5e507 100644 --- a/src/lib/Error.ts +++ b/src/lib/Error.ts @@ -10,7 +10,21 @@ export class EmailAlreadyExistsError extends Error { } } -export class NotFoundError extends Error {} +export class NotFoundError extends Error { + public code: number + constructor(message: string) { + super(message) + this.code = 404 + } +} + +export class BadRequestError extends Error { + public code: number + constructor(message: string) { + super(message) + this.code = 400 + } +} export class ServerError extends Error { public code: number diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..2efcfa7 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,59 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import pino from 'pino' +import 'dotenv/config' + +const pinoConfig = pino({ + level: process.env.PINO_LOG_LEVEL || 'info', + formatters: { + level: (label) => { + return { level: label.toUpperCase() } + }, + }, + timestamp: pino.stdTimeFunctions.isoTime, +}) + +type Level = 'info' | 'error' | 'warn' +type Layer = 'repository' | 'service' | 'handler' +type LoggerContext = { + operationId: string + layer: Layer + requestId?: string + userId?: number +} + +function logger( + level: Level, + message: string, + layer: Layer, + operationId: string, + context?: LoggerContext, +) { + if (context) { + pinoConfig[level]({ + message, + context: { + operationId: operationId, + layer: layer, + userId: context?.userId, + requestId: context?.requestId, + }, + }) + } + pinoConfig[level]({ message, layer, operationId }) +} + +const asyncLocalStorage = new AsyncLocalStorage() + +export function getContext() { + return asyncLocalStorage.getStore() +} + +export type Logger = ( + level: Level, + message: string, + layer: Layer, + operationId: string, + context?: LoggerContext, +) => void + +export default logger diff --git a/src/lib/token.ts b/src/lib/token.ts index 9be599a..254bcb5 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,5 +1,6 @@ import { type JwtPayload, sign, verify } from 'jsonwebtoken' import 'dotenv/config' +import type { UserInToken } from '../schema' /** * 10 minutes in ms @@ -10,11 +11,8 @@ export const accessTokenMaxAge = 600000 */ export const refreshTokenMaxAge = 864000000 const tokenSecret = process.env.TOKEN_SECRET as string -type decodedType = JwtPayload & { - userId: number - fullname: string - email: string -} + +type decodedType = JwtPayload & UserInToken export function verifyToken(token: string): { decodedData: decodedType | null @@ -33,15 +31,15 @@ export function verifyToken(token: string): { export function createNewToken(data: { fullname: string email: string - userId: number expiration: number + role: 'ADMIN' | 'USER' }) { const token = sign( { tokenId: Math.random(), fullname: data.fullname, + role: data.role, email: data.email, - userId: data.userId, }, tokenSecret, { diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 3f04e37..ea63c60 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -1,27 +1,18 @@ -import { randomUUID } from 'node:crypto' import path from 'node:path' +import { v2 as cloudinary } from 'cloudinary' import type { Request } from 'express' import multer from 'multer' +import { CloudinaryStorage } from 'multer-storage-cloudinary' +import 'dotenv/config' -const PRODUCT_PHOTO = 'images' +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME as string, + api_key: process.env.CLOUDINARY_API_KEY as string, + api_secret: process.env.CLOUDINARY_API_SECRET as string, +}) -const storage = multer.diskStorage({ - filename(_, file, callback) { - const uniqueFilename = `${randomUUID()}${path.extname(file.originalname)}` - callback(null, uniqueFilename) - }, - destination(_, file, callback) { - let dir: string - switch (file.fieldname) { - case PRODUCT_PHOTO: - dir = path.join(__dirname, '../../public/img/product-photo') - break - default: - dir = path.join(__dirname, '../../public/img') - break - } - callback(null, dir) - }, +const cloudinaryStorage = new CloudinaryStorage({ + cloudinary: cloudinary, }) const fileFilter = (_: Request, file: Express.Multer.File, callback: any) => { @@ -42,7 +33,7 @@ const fileFilter = (_: Request, file: Express.Multer.File, callback: any) => { } export default multer({ - storage, + storage: cloudinaryStorage, fileFilter, limits: { fileSize: 1048576 /* 1MB */ }, }) diff --git a/src/middlewares/adminAccess.ts b/src/middlewares/adminAccess.ts new file mode 100644 index 0000000..d53f52a --- /dev/null +++ b/src/middlewares/adminAccess.ts @@ -0,0 +1,24 @@ +import type { NextFunction, Request, Response } from 'express' +import type ApiResponse from '../schema' +import type { UserInToken } from '../schema' + +function adminAccess( + _: Request, + res: Response, + next: NextFunction, +) { + console.log('user role in adminAccess middleware: ', res.locals.user) + if (res.locals.user.role !== 'ADMIN') { + return res.status(403).json({ + status: 'fail', + errors: { + code: 403, + message: 'this action is only for user with admin access', + }, + }) + } + + return next() +} + +export default adminAccess diff --git a/src/middlewares/validateInput.ts b/src/middlewares/validateInput.ts index d617170..9affd44 100644 --- a/src/middlewares/validateInput.ts +++ b/src/middlewares/validateInput.ts @@ -1,12 +1,11 @@ import type { NextFunction, Request, Response } from 'express' import type { AnyZodObject } from 'zod' import type ApiResponse from '../schema' -import type { FailResponse, SuccessResponse } from '../schema' export function validateInput(schema: AnyZodObject) { return async ( req: Request, - res: Response>, + res: Response>, next: NextFunction, ) => { try { diff --git a/src/product/handler.ts b/src/product/handler.ts index 454f0ce..9931df2 100644 --- a/src/product/handler.ts +++ b/src/product/handler.ts @@ -31,7 +31,7 @@ class ProductHandler { ) => { let productPhotos: string[] if (req.files?.length) { - productPhotos = req.files.map((file) => file.filename) + productPhotos = req.files.map((file) => file.path) } else { productPhotos = [''] } @@ -68,7 +68,7 @@ class ProductHandler { ) => { let newProductPhotos: string[] if (req.files?.length) { - newProductPhotos = req.files.map((file) => file.filename) + newProductPhotos = req.files.map((file) => file.path) } else { newProductPhotos = [''] } diff --git a/src/product/repository.ts b/src/product/repository.ts index 8656ca4..c650dc8 100644 --- a/src/product/repository.ts +++ b/src/product/repository.ts @@ -1,88 +1,218 @@ -import type { Pool, ResultSetHeader, RowDataPacket } from 'mysql2/promise' -import { NotFoundError, ServerError } from '../lib/Error' -import type { CreateProduct, FlattenUpdateProduct, Product } from './schema' +import type { PrismaClient } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' +import { BadRequestError, NotFoundError, ServerError } from '../lib/Error' +import { type Logger, getContext } from '../lib/logger' +import type { CreateProduct, FlattenUpdateProduct } from './schema' class ProductRepository { - constructor(private db: Pool) {} + constructor( + private prisma: PrismaClient, + private logger: Logger, + ) {} + private RELATED_RECORD_NOT_EXIST = 'P2025' async createProduct(data: CreateProduct) { - console.log('product repo create: ', data) + try { + const newProduct = await this.prisma.products.create({ + data: { + name: data.name, + description: data.description, + price: data.price, + images: data.images || [''], + }, + }) - const [rows] = await this.db.execute( - 'INSERT INTO products (name, description, price, images) VALUES(?,?,?,?)', - [data.name, data.description, data.price, data.images || ['']], - ) - - const result = rows as ResultSetHeader - if (!result.affectedRows) { + return { affectedRows: 1, id: newProduct.id } + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'repository', + 'createProduct', + context, + ) throw new ServerError( - "fail to create product, this is not your fault and we're working on this. please try again later", + 'fail to create product and this is not your fault, please try again later', ) } - - return { affectedRows: result.affectedRows, id: result.insertId } } - async getProductById(id: number) { - const [rows] = await this.db.query( - 'SELECT id, name, description, price, images FROM products WHERE id = ?', - [id], - ) - if (!rows.length) { + async getProductById(id: bigint) { + try { + const product = await this.prisma.products.findUniqueOrThrow({ + where: { + id: id, + }, + select: { + id: true, + name: true, + description: true, + images: true, + price: true, + }, + }) + return { + id: product.id, + name: product.name, + description: product.description, + price: product.price, + images: product.images as string[], + } + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + `product not found ${error.message || error}`, + 'repository', + 'getProductById', + context, + ) throw new NotFoundError( 'product not found, enter the correct information and try again', ) } - return rows[0] as unknown as Product } async getProducts(lastProductId?: number) { + const context = getContext() if (lastProductId) { - const [rows] = await this.db.query( - 'SELECT id, name, description, price, images FROM products WHERE id > ? ORDER BY id ASC LIMIT 30', - [lastProductId], - ) - if (!rows.length) { + const products = await this.prisma.products.findMany({ + take: 30, + skip: 1, + orderBy: { + id: 'asc', + }, + cursor: { + id: lastProductId, + }, + select: { + id: true, + name: true, + description: true, + images: true, + }, + }) + if (!products.length) { + this.logger( + 'error', + 'no products found', + 'repository', + 'getProducts', + context, + ) throw new NotFoundError('no products found') } - return rows as unknown as Product[] + return products } - const [rows] = await this.db.query( - 'SELECT id, name, description, price, images FROM products ORDER BY id ASC LIMIT 30', - ) - if (!rows.length) { + const products = await this.prisma.products.findMany({ + take: 30, + orderBy: { + id: 'asc', + }, + select: { + id: true, + name: true, + description: true, + images: true, + }, + }) + + if (!products.length) { + this.logger( + 'error', + 'no products found', + 'repository', + 'getProducts', + context, + ) throw new NotFoundError('no products found') } - return rows as unknown as Product[] + return products } async deleteProductById(id: number) { - const [rows] = await this.db.execute('DELETE FROM products WHERE id = ?', [ - id, - ]) - const result = rows as ResultSetHeader - if (!result.affectedRows) { - throw new NotFoundError( - "you are trying to delete the product that does'nt exists", + try { + await this.prisma.products.delete({ + where: { + id: id, + }, + }) + + return { affectedRows: 1 } + } catch (error: any) { + const context = getContext() + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === this.RELATED_RECORD_NOT_EXIST) { + this.logger( + 'error', + `delete product fail, product not found: ${error.message}`, + 'repository', + 'deleteProduct', + context, + ) + throw new NotFoundError( + "you are trying to delete the product that does'nt exists", + ) + } + } + this.logger( + 'error', + `delete product fail, bad request: ${error.message}`, + 'repository', + 'deleteProduct', + context, ) + throw new BadRequestError('delete product fail, please try again later') } - - return { affectedRows: result.affectedRows } } - async updateProductById(id: number, data: FlattenUpdateProduct) { - const [rows] = await this.db.execute( - 'UPDATE products SET name = ?, description = ?, price = ?, images = ? WHERE id = ?', - [data.name, data.description, data.price, data.images, id], - ) - const result = rows as ResultSetHeader - if (!result.affectedRows) { - throw new Error( + async updateProductById(id: bigint, data: FlattenUpdateProduct) { + try { + const updatedProduct = await this.prisma.products.update({ + where: { + id: id, + }, + data: { + name: data.name, + description: data.description, + price: data.price, + images: data.images, + }, + select: { + id: true, + name: true, + description: true, + price: true, + images: true, + }, + }) + return { affectedRows: 1, ...updatedProduct } + } catch (error: any) { + const context = getContext() + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === this.RELATED_RECORD_NOT_EXIST) { + this.logger( + 'error', + `fail to update product, product not found ${error.message}`, + 'repository', + 'updateProductById', + context, + ) + throw new NotFoundError('fail to update product, product not found') + } + } + this.logger( + 'error', + `fail to update product, bad request: ${error.message || error}`, + 'repository', + 'updateProductById', + context, + ) + throw new BadRequestError( 'failed to update product, enter the correct information and try again', ) } - - return { affectedRows: result.affectedRows, id } } } diff --git a/src/product/schema.ts b/src/product/schema.ts index 7f6cbd4..5733ca7 100644 --- a/src/product/schema.ts +++ b/src/product/schema.ts @@ -33,7 +33,7 @@ export const updateProduct = z.object({ }) export type CreateProduct = z.TypeOf -export type Product = z.TypeOf & { id: number } +export type Product = z.TypeOf & { id: bigint } export type UpdateProduct = z.TypeOf export type UpdateProductDataService = { name: string diff --git a/src/product/service.test.ts b/src/product/service.test.ts index fc16b3a..03ef5e6 100644 --- a/src/product/service.test.ts +++ b/src/product/service.test.ts @@ -1,4 +1,5 @@ import { ServerError } from '../lib/Error' +import logger from '../lib/logger' import type ProductRepository from './repository' import type { CreateProduct, Product } from './schema' import ProductService from './service' @@ -12,23 +13,26 @@ describe('product service', () => { price: 10000, } const PRODUCT: Product = { - id: 1, + id: 1n, name: 'cheese cake', description: 'cheese cake can make your day like sunday', price: 10000, + images: ['image-1.png'], } const PRODUCTS: Product[] = [ { - id: 1, + id: 1n, name: 'cheese cake', description: 'cheese cake can make your day like sunday', price: 10000, + images: ['image-1.png'], }, { - id: 30, + id: 30n, name: 'chocolate cake', description: 'chocolate cake can make your day great', price: 10000, + images: ['image-1.png'], }, ] @@ -39,7 +43,7 @@ describe('product service', () => { getProducts: jest.fn(), } as unknown as jest.Mocked - productService = new ProductService(productRepo) + productService = new ProductService(productRepo, logger) }) describe('create product', () => { @@ -63,8 +67,9 @@ describe('product service', () => { it('should success create new product', async () => { productRepo.createProduct.mockResolvedValueOnce({ affectedRows: 1, - id: 1, + id: 1n, }) + // @ts-ignore productRepo.getProductById.mockResolvedValueOnce(PRODUCT) const result = await productService.createProduct(NEW_VALID_PRODUCT) @@ -88,6 +93,7 @@ describe('product service', () => { }) it('should return first 30 products', async () => { + // @ts-ignore productRepo.getProducts.mockResolvedValueOnce(PRODUCTS) const result = await productService.getProducts() @@ -95,12 +101,17 @@ describe('product service', () => { if (result.status !== 'success') { fail('product service return fail expected success') } - expect(result.data?.meta?.lastProductId).toBe(30) + expect(result.data?.meta?.lastProductId).toBe(30n) expect(result.data?.products?.length).toBe(2) }) it('should return 30 products after the last id', async () => { productRepo.getProducts.mockResolvedValueOnce([ - { id: 30, name: 'choco', description: 'choco cake', price: 10000 }, + { + id: 30n, + name: 'choco', + description: 'choco cake', + images: ['image-1.png'], + }, ]) const result = await productService.getProducts('1') @@ -108,7 +119,7 @@ describe('product service', () => { if (result.status !== 'success') { fail('product service status return other than success') } - expect(result.data?.meta?.lastProductId).toBe(30) + expect(result.data?.meta?.lastProductId).toBe(30n) expect(result.data?.products.length).toBe(1) }) }) diff --git a/src/product/service.ts b/src/product/service.ts index e240192..2abf606 100644 --- a/src/product/service.ts +++ b/src/product/service.ts @@ -1,4 +1,6 @@ +import type { JsonValue } from '@prisma/client/runtime/library' import { NotFoundError } from '../lib/Error' +import { type Logger, getContext } from '../lib/logger' import type ApiResponse from '../schema' import type { CreateProduct, @@ -10,18 +12,34 @@ import type { interface ProductRepository { createProduct( data: CreateProduct, - ): Promise<{ id: number; affectedRows: number }> - getProductById(id: number): Promise - getProducts(lastProductId?: number): Promise + ): Promise<{ id: bigint; affectedRows: number }> + getProductById(id: bigint): Promise<{ + name: string + description: string + images: string[] + id: bigint + price: number + }> + getProducts(lastProductId?: number): Promise< + { + name: string + description: string + images: JsonValue + id: bigint + }[] + > deleteProductById(id: number): Promise<{ affectedRows: number }> updateProductById( - id: number, + id: bigint, data: FlattenUpdateProduct, - ): Promise<{ affectedRows: number; id: number }> + ): Promise<{ affectedRows: number; id: bigint }> } class ProductService { - constructor(private repo: ProductRepository) {} + constructor( + private repo: ProductRepository, + private logger: Logger, + ) {} createProduct = async ( data: CreateProduct, @@ -42,9 +60,15 @@ class ProductService { }, }, } - } catch (error) { - console.log('product service error: ', error) - + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'createProduct', + context, + ) return { status: 'fail', errors: { @@ -70,7 +94,15 @@ class ProductService { products: result, }, } - } catch (error) { + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'getProducts', + context, + ) return { status: 'fail', errors: { @@ -96,7 +128,15 @@ class ProductService { affectedRows: result.affectedRows, }, } - } catch (error) { + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'deleteProductById', + context, + ) return { status: 'fail', errors: { @@ -111,7 +151,7 @@ class ProductService { id: string, data: UpdateProductDataService, ): Promise> => { - const parsedId = Number.parseInt(id, 10) + const parsedId = BigInt(id) try { if (Number.isNaN(parsedId)) { throw new NotFoundError( @@ -127,7 +167,6 @@ class ProductService { .filter((image) => !data.images.removed.includes(image)) .filter(Boolean) if (finalImages.length > 5) { - console.log(finalImages) throw new Error('each product can only have 5 images at max') } const updatedProduct = await this.repo.updateProductById(parsedId, { @@ -149,7 +188,15 @@ class ProductService { }, }, } - } catch (error) { + } catch (error: any) { + const context = getContext() + this.logger( + 'error', + error.message || error, + 'service', + 'updateProductById', + context, + ) return { status: 'fail', errors: { diff --git a/src/routes.ts b/src/routes.ts index f4d66d5..eca377d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,62 +1,64 @@ -import type { Express } from 'express' +import { Router } from 'express' import { loginSchema } from './auth/schema' import { deserializeToken } from './middlewares/deserializeToken' import requiredLogin from './middlewares/requiredLogin' import { validateInput } from './middlewares/validateInput' import { createUserSchema } from './user/schema' -import connection from '../db/connection' +import { PrismaClient } from '@prisma/client' import AuthHandler from './auth/handler' import AuthRepository from './auth/repository' import AuthService from './auth/service' +import logger from './lib/logger' import multer from './lib/upload' +import adminAccess from './middlewares/adminAccess' import formDataParse from './middlewares/formDataParser' -import sanitizeInput from './middlewares/sanitizeInput' import ProductHandler from './product/handler' import ProductRepository from './product/repository' import { createProduct, updateProduct } from './product/schema' import ProductService from './product/service' import UserHandler from './user/handler' -import UserSerivce from './user/service' - -export default function routes(app: Express) { - const authRepo = new AuthRepository(connection) - const authService = new AuthService(authRepo) - const authHandler = new AuthHandler(authService) - - const userService = new UserSerivce(authRepo) - const userHandler = new UserHandler(userService) - - const productRepo = new ProductRepository(connection) - const productService = new ProductService(productRepo) - const productHandler = new ProductHandler(productService) - - app.use(sanitizeInput) - app.post( - '/api/register', - //@ts-ignore - validateInput(createUserSchema), - authHandler.registerUser, - ) - app.post('/api/login', validateInput(loginSchema), authHandler.login) - app.get('/api/refresh', authHandler.refreshToken) - - app.use(deserializeToken) - app.use(requiredLogin) - app.get('/api/users', userHandler.getCurrentUser) - app.get('/api/users/:id', userHandler.getUserById) - - app.post( - '/api/products', - formDataParse(multer.array('images', 5)), - validateInput(createProduct), - productHandler.createProduct, - ) - app.get('/api/products', productHandler.getProducts) - app.put( - '/api/products/:id', - formDataParse(multer.array('images', 5)), - validateInput(updateProduct), - productHandler.updateProductById, - ) -} + +const prisma = new PrismaClient() +const authRepo = new AuthRepository(prisma, logger) +const authService = new AuthService(authRepo, logger) +const authHandler = new AuthHandler(authService) + +const userHandler = new UserHandler() + +const productRepo = new ProductRepository(prisma, logger) +const productService = new ProductService(productRepo, logger) +const productHandler = new ProductHandler(productService) + +const router = Router() + +router.post( + '/register', + //@ts-ignore + validateInput(createUserSchema), + authHandler.registerUser, +) +router.post('/login', validateInput(loginSchema), authHandler.login) +router.get('/refresh', authHandler.refreshToken) + +router.use(deserializeToken) +router.use(requiredLogin) +router.get('/users', userHandler.getCurrentUser) + +router.post( + '/products', + formDataParse(multer.array('images', 5)), + adminAccess, + validateInput(createProduct), + productHandler.createProduct, +) +router.get('/products', productHandler.getProducts) +router.put( + '/products/:id', + formDataParse(multer.array('images', 5)), + adminAccess, + validateInput(updateProduct), + productHandler.updateProductById, +) + +export default router diff --git a/src/schema.ts b/src/schema.ts index 2cd5b0b..9fc3e70 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -11,4 +11,10 @@ type ApiResponse = } } +export type UserInToken = { + fullname: string + email: string + role: 'ADMIN' | 'USER' +} + export default ApiResponse diff --git a/src/user/handler.ts b/src/user/handler.ts index 4b1bbf5..5149895 100644 --- a/src/user/handler.ts +++ b/src/user/handler.ts @@ -2,25 +2,7 @@ import type { Request, Response } from 'express' import type ApiResponse from '../schema' import type { User } from './schema' -interface UserService { - getUserById(idFromUrlPath: string): Promise<{ response: ApiResponse }> -} - class UserHandler { - constructor(public service: UserService) {} - - getUserById = async ( - req: Request<{ id: string }>, - res: Response>, - ) => { - const result = await this.service.getUserById(req.params.id) - if (result.response.status === 'fail') { - return res.status(result.response.errors.code).json(result.response) - } - - return res.status(200).json(result.response) - } - getCurrentUser = async ( req: Request, res: Response< diff --git a/src/user/repository.ts b/src/user/repository.ts deleted file mode 100644 index cdbd1b9..0000000 --- a/src/user/repository.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Pool, RowDataPacket } from 'mysql2/promise' - -class UserRepository { - constructor(private db: Pool) {} - - async getUserById( - id: number, - ): Promise<{ id: number; fullname: string; email: string }> { - const [rows] = await this.db.query( - 'SELECT id, fullname, email FROM users WHERE id = ?', - [id], - ) - - if (!rows.length) { - throw new Error('user not found') - } - - return { - id: rows[0]?.id, - fullname: rows[0]?.fullname, - email: rows[0]?.email, - } - } -} - -export default UserRepository diff --git a/src/user/service.test.ts b/src/user/service.test.ts deleted file mode 100644 index 0521e61..0000000 --- a/src/user/service.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type UserRepository from './repository' -import UserSerivce from './service' - -describe('user service', () => { - let userRepo: jest.Mocked - let userService: UserSerivce - const VALID_EMAIL = 'testing@email.com' - const FULLNAME = 'testing' - - beforeEach(() => { - userRepo = { - USER_ALREADY_EXISTS: 1062, - createUser: jest.fn(), - getUserById: jest.fn(), - } as unknown as jest.Mocked - - userService = new UserSerivce(userRepo) - }) - - describe('get user by id', () => { - it('should fail: user not found cause bad user id', async () => { - const user = await userService.getUserById('bad_user_id') - expect(user.response.status).toBe('fail') - if (user.response.status === 'fail') { - expect(user.response.errors.message).toBe('user not found') - } - }) - - it("should fail: user not found cause user id doesn't exist", async () => { - userRepo.getUserById.mockRejectedValue('user not found') - const user = await userService.getUserById('99999') - - expect(user.response.status).toBe('fail') - if (user.response.status === 'fail') { - expect(user.response.errors.message).toBe('user not found') - } - }) - - it('should success', async () => { - userRepo.getUserById.mockResolvedValue({ - id: 1, - fullname: FULLNAME, - email: VALID_EMAIL, - }) - const user = await userService.getUserById('1') - - expect(user.response.status).toBe('success') - expect(user.response).toEqual({ - status: 'success', - data: { - user: { - id: 1, - email: VALID_EMAIL, - fullname: FULLNAME, - }, - }, - }) - }) - }) -}) diff --git a/src/user/service.ts b/src/user/service.ts deleted file mode 100644 index 2398e96..0000000 --- a/src/user/service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Auth } from '../lib/Auth' -import type ApiResponse from '../schema' -import type { CreateUser, User } from './schema' - -interface UserRepository { - getUserById(id: number): Promise> -} - -class UserSerivce extends Auth { - constructor(public repo: UserRepository) { - super() - } - - public getUserById = async ( - idFromUrlPath: string, - ): Promise<{ response: ApiResponse }> => { - try { - const id = Number.parseInt(idFromUrlPath, 10) - if (Number.isNaN(id)) { - throw new Error('user not found') - } - const user = await this.repo.getUserById(id) - if (!user.email || !user.fullname) { - throw new Error('create user is fail, please try again') - } - return { - response: { - status: 'success', - data: { user: { id, fullname: user.fullname, email: user.email } }, - }, - } - } catch (error: any) { - return { - response: { - status: 'fail', - errors: { code: 404, message: error.message || error }, - }, - } - } - } -} - -export default UserSerivce diff --git a/tsconfig.json b/tsconfig.json index 65d357b..1617f06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,33 +1,17 @@ { "compilerOptions": { - // project options - "lib": ["ESNext", "dom"], // specifies which default set of type definitions to use ("DOM", "ES6", etc) - "outDir": "lib", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory., - "removeComments": true, // Strips all comments from TypeScript files when converting into JavaScript- you rarely read compiled code so this saves space - "target": "ES6", // Target environment. Most modern browsers support ES6, but you may want to set it to newer or older. (defaults to ES3) - - // Module resolution - "baseUrl": "./", // Lets you set a base directory to resolve non-absolute module names. - "esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module - "moduleResolution": "node", // Pretty much always node for modern JS. Other option is "classic" - "paths": {}, // A series of entries which re-map imports to lookup locations relative to the baseUrl - - // Source Map - "sourceMap": true, // enables the use of source maps for debuggers and error reporting etc - "sourceRoot": "/", // Specify the location where a debugger should locate TypeScript files instead of relative source locations. - - // Strict Checks - "alwaysStrict": true, // Ensures that your files are parsed in the ECMAScript strict mode, and emit “use strict” for each source file. - "allowUnreachableCode": false, // pick up dead code paths - "noImplicitAny": true, // In some cases where no type annotations are present, TypeScript will fall back to a type of any for a variable when it cannot infer the type. - "strictNullChecks": true, // When strictNullChecks is true, null and undefined have their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected. - - // Linter Checks - "noImplicitReturns": true, - "noUncheckedIndexedAccess": true, // accessing index must always check for undefined - "noUnusedLocals": true, // Report errors on unused local variables. - "noUnusedParameters": true // Report errors on unused parameters in functions - }, - "include": ["./**/*.ts"], - "exclude": ["node_modules/**/*"] + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "module": "NodeNext", + "outDir": "dist", + "sourceMap": true + } } diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..7beddb0 --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "builds": [ + { + "src": "src/index.ts", + "use": "@vercel/node" + } + ], + "rewrites": [ + { + "source": "/(.*)", + "destination": "/src/index.ts" + } + ] +}