From 1da952937c92ac87ec461f7976dee03dfb7a7624 Mon Sep 17 00:00:00 2001 From: Decal Date: Thu, 17 Apr 2025 23:20:36 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20mock=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/prisma/mock/articleMock.js | 10 +++++----- src/db/prisma/mock/productMock.js | 5 ----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/db/prisma/mock/articleMock.js b/src/db/prisma/mock/articleMock.js index 85e5cfe7..9bd7c711 100644 --- a/src/db/prisma/mock/articleMock.js +++ b/src/db/prisma/mock/articleMock.js @@ -29,23 +29,23 @@ const ARTICLE_MOCK = [ const ARTICLE_COMMENT_MOCK = [ { content: "댓글 시드 데이터 1", - articleId: 33, + articleId: 1, }, { content: "댓글 시드 데이터 2", - articleId: 34, + articleId: 2, }, { content: "댓글 시드 데이터 3", - articleId: 35, + articleId: 3, }, { content: "댓글 시드 데이터 4", - articleId: 36, + articleId: 4, }, { content: "댓글 시드 데이터 5", - articleId: 37, + articleId: 5, }, ]; diff --git a/src/db/prisma/mock/productMock.js b/src/db/prisma/mock/productMock.js index 3777e051..17edd197 100644 --- a/src/db/prisma/mock/productMock.js +++ b/src/db/prisma/mock/productMock.js @@ -3,31 +3,26 @@ const PRODUCT_MOCK = [ name: "상품 시드 데이터 1", description: "상품 설명 시드 데이터 1", price: 10000, - tags: "상품 태그 시드 데이터 1", }, { name: "상품 시드 데이터 2", description: "상품 설명 시드 데이터 2", price: 20000, - tags: "상품 태그 시드 데이터 2", }, { name: "상품 시드 데이터 3", description: "상품 설명 시드 데이터 3", price: 30000, - tags: "상품 태그 시드 데이터 3", }, { name: "상품 시드 데이터 4", description: "상품 설명 시드 데이터 4", price: 40000, - tags: "상품 태그 시드 데이터 4", }, { name: "상품 시드 데이터 5", description: "상품 설명 시드 데이터 5", price: 50000, - tags: "상품 태그 시드 데이터 5", }, ]; From 8ac7fec868308e440a163232e9dc80298bd74e1f Mon Sep 17 00:00:00 2001 From: Decal Date: Fri, 18 Apr 2025 00:11:23 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20schema=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20250417150745_modify_article_table/migration.sql | 9 +++++++++ src/db/prisma/schema.prisma | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql diff --git a/src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql b/src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql new file mode 100644 index 00000000..fd63d8f7 --- /dev/null +++ b/src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `creatdAt` on the `Article` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Article" DROP COLUMN "creatdAt", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index d93f9939..e8026e04 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -77,7 +77,7 @@ model Article { id Int @id @default(autoincrement()) title String content String - creatdAt DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) // authorId String From 2b42f93436b1025a442eb88be72a281ff114d0a5 Mon Sep 17 00:00:00 2001 From: Decal Date: Fri, 18 Apr 2025 00:24:25 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20articleRoutes.js=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/articleRoutes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/articleRoutes.js b/src/routes/articleRoutes.js index 785deb8c..e1cc4620 100644 --- a/src/routes/articleRoutes.js +++ b/src/routes/articleRoutes.js @@ -18,7 +18,7 @@ articleRouter.get("/", async (req, res, next) => { where: filter, skip: (Number(offset) - 1) * Number(limit) || 0, take: Number(limit) || 10, - orderBy: { creatdAt: orderBy === "recent" ? "desc" : "asc" }, + orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, omit: { updatedAt: true }, }); From cecbbb0f2cdb2299a53da64030be56206466fbd5 Mon Sep 17 00:00:00 2001 From: Decal Date: Sat, 19 Apr 2025 01:28:27 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20onDelete:=20Cascade=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 17 +++++++++++++++++ src/db/prisma/schema.prisma | 8 ++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql diff --git a/src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql b/src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql new file mode 100644 index 00000000..06643e54 --- /dev/null +++ b/src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql @@ -0,0 +1,17 @@ +-- DropForeignKey +ALTER TABLE "ArticleComment" DROP CONSTRAINT "ArticleComment_articleId_fkey"; + +-- DropForeignKey +ALTER TABLE "ProductComment" DROP CONSTRAINT "ProductComment_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "ProductLike" DROP CONSTRAINT "ProductLike_productId_fkey"; + +-- AddForeignKey +ALTER TABLE "ProductComment" ADD CONSTRAINT "ProductComment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductLike" ADD CONSTRAINT "ProductLike_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleComment" ADD CONSTRAINT "ArticleComment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index e8026e04..c66b667e 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -56,9 +56,9 @@ model ProductComment { id Int @id @default(autoincrement()) content String createdAt DateTime @default(now()) - // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) // authorId String - product Product @relation(fields: [productId], references: [id]) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId Int } @@ -68,7 +68,7 @@ model ProductLike { createdAt DateTime @default(now()) // user User @relation(fields: [userId], references: [id], onDelete: SetNull) // userId String - product Product @relation(fields: [productId], references: [id]) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId Int } @@ -91,6 +91,6 @@ model ArticleComment { createdAt DateTime @default(now()) // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) // authorId String - article Article @relation(fields: [articleId], references: [id]) + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) articleId Int } From 6b9f7f25dd3fc3baebd4d18e78449a5937491fb7 Mon Sep 17 00:00:00 2001 From: Decal Date: Fri, 9 May 2025 15:55:40 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20cursor=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/commentRoutes.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/routes/commentRoutes.js b/src/routes/commentRoutes.js index 883c9ad6..e089ac1b 100644 --- a/src/routes/commentRoutes.js +++ b/src/routes/commentRoutes.js @@ -110,9 +110,11 @@ commentRouter.get(`${PRODUCT_COMMENT}`, async (req, res, next) => { const { limit, cursor } = req.query; await prisma.$transaction(async (tx) => { - const productCommentId = await tx.productComment.findFirst({ - where: { productId, id: Number(cursor) }, - }); + const productCommentId = cursor + ? await tx.productComment.findFirst({ + where: { productId, id: Number(cursor) }, + }) + : false; const productComment = await tx.productComment.findMany({ where: { productId }, From 115c297d4694f47d80ceb8a0ae4a831569507add Mon Sep 17 00:00:00 2001 From: Decal Date: Fri, 9 May 2025 16:11:29 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/commentRoutes.js | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/routes/commentRoutes.js b/src/routes/commentRoutes.js index e089ac1b..08db09a9 100644 --- a/src/routes/commentRoutes.js +++ b/src/routes/commentRoutes.js @@ -148,4 +148,56 @@ commentRouter.post(`${PRODUCT_COMMENT}`, async (req, res, next) => { } }); +// 상품 댓글 수정 +commentRouter.patch(`${PRODUCT_COMMENT}/:commentId`, async (req, res, next) => { + try { + const productId = Number(req.params.productId); + const commentId = Number(req.params.commentId); + const { content } = req.body; + + await prisma.$transaction(async (tx) => { + const productComment = await tx.productComment.findUnique({ + where: { productId, id: commentId }, + }); + if (!productComment) throw new Error("존재하지 않는 댓글입니다."); + + const updateProductComment = await tx.productComment.update({ + where: { productId, id: commentId }, + data: { content }, + }); + + res.status(200).json(updateProductComment); + }); + } catch (e) { + next(e); + } +}); + +// 상품 댓글 삭제 +commentRouter.delete( + `${PRODUCT_COMMENT}/:commentId`, + async (req, res, next) => { + const productId = Number(req.params.productId); + const commentId = Number(req.params.commentId); + try { + await prisma.$transaction(async (tx) => { + const productComment = await tx.productComment.findUnique({ + where: { productId, id: commentId }, + }); + if (!productComment) { + throw new Error("이미 삭제된 댓글입니다."); + } + + await tx.productComment.delete({ + where: { productId, id: commentId }, + }); + + res.sendStatus(204); + }); + } catch (e) { + next(e); + } + } +); + export default commentRouter; From 329c8573603963a0e715c75f6699eaec8fa01cfe Mon Sep 17 00:00:00 2001 From: Decal Date: Tue, 13 May 2025 00:50:29 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20User=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80,=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=83=9D=EC=84=B1,=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=93=9C=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B3=90=20=EC=A0=81=EC=9A=A9(product,=20article)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + package-lock.json | 755 +++++++++++++++++- package.json | 12 +- {src/db/prisma => prisma}/client.prisma.js | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20250320094435_add_tag_tags/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20250321135508_add_user/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 14 + .../migration.sql | 8 + .../migration.sql | 34 + .../migrations/migration_lock.toml | 0 {src/db/prisma => prisma}/mock/articleMock.js | 0 {src/db/prisma => prisma}/mock/productMock.js | 0 {src/db/prisma => prisma}/schema.prisma | 52 +- {src/db/prisma => prisma}/seed.js | 0 src/app.js | 5 +- src/controllers/articleController.js | 73 ++ src/controllers/productController.js | 73 ++ src/middlewares/errorHandler.js | 11 + src/middlewares/handleErrorMiddleware.js | 25 - src/middlewares/requiredDataValidate.js | 46 ++ src/repositories/articleRepository.js | 65 ++ src/repositories/productRepository.js | 86 ++ src/routes/articleRoutes.js | 99 +-- src/routes/commentRoutes.js | 2 +- src/routes/indexRoutes.js | 2 +- src/routes/productRoutes.js | 139 +--- src/routes/userRoutes.js | 96 ++- src/services/articleService.js | 93 +++ src/services/productService.js | 137 ++++ src/utils/index.js | 0 39 files changed, 1501 insertions(+), 329 deletions(-) create mode 100644 .env.example rename {src/db/prisma => prisma}/client.prisma.js (100%) rename {src/db/prisma => prisma}/migrations/20250318131126_add_first_table/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250318145107_add_product_rel/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250319010554_remove_user_table/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250320094435_add_tag_tags/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250320094633_modify_product_table/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250320171240_add_product_tag_on_delete_cascade/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250321001436_add_tag_name_unique/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250321003748_change_on_delete_attribute/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250321135508_add_user/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250417150745_modify_article_table/migration.sql (100%) rename {src/db/prisma => prisma}/migrations/20250418155732_add_on_delete_cascade/migration.sql (100%) create mode 100644 prisma/migrations/20250509082904_add_user_field/migration.sql create mode 100644 prisma/migrations/20250509114208_add_nickname_attribute/migration.sql create mode 100644 prisma/migrations/20250512040355_add_like_model/migration.sql rename {src/db/prisma => prisma}/migrations/migration_lock.toml (100%) rename {src/db/prisma => prisma}/mock/articleMock.js (100%) rename {src/db/prisma => prisma}/mock/productMock.js (100%) rename {src/db/prisma => prisma}/schema.prisma (67%) rename {src/db/prisma => prisma}/seed.js (100%) create mode 100644 src/controllers/articleController.js create mode 100644 src/controllers/productController.js create mode 100644 src/middlewares/errorHandler.js delete mode 100644 src/middlewares/handleErrorMiddleware.js create mode 100644 src/middlewares/requiredDataValidate.js create mode 100644 src/repositories/articleRepository.js create mode 100644 src/repositories/productRepository.js create mode 100644 src/services/articleService.js create mode 100644 src/services/productService.js create mode 100644 src/utils/index.js diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b34fa0b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL= +PORT= +JWT_SECRET= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5ebaea9e..510353f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@prisma/client": "^6.5.0", + "@prisma/client": "^6.7.0", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "@types/express": "^5.0.1", "@types/node": "^22.13.10", "nodemon": "^3.1.9", - "prisma": "^6.5.0", + "prisma": "^6.7.0", "tsx": "^4.19.3", "typescript": "^5.8.2" } @@ -448,10 +450,30 @@ "node": ">=18" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@prisma/client": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.5.0.tgz", - "integrity": "sha512-M6w1Ql/BeiGoZmhMdAZUXHu5sz5HubyVcKukbLs3l0ELcQb8hTUJxtGEChhv4SVJ0QJlwtLnwOLgIRQhpsm9dw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.7.0.tgz", + "integrity": "sha512-+k61zZn1XHjbZul8q6TdQLpuI/cvyfil87zqK2zpreNIXyXtpUv3+H/oM69hcsFcZXaokHJIzPAt5Z8C8eK2QA==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -471,9 +493,9 @@ } }, "node_modules/@prisma/config": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.5.0.tgz", - "integrity": "sha512-sOH/2Go9Zer67DNFLZk6pYOHj+rumSb0VILgltkoxOjYnlLqUpHPAN826vnx8HigqnOCxj9LRhT6U7uLiIIWgw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.7.0.tgz", + "integrity": "sha512-di8QDdvSz7DLUi3OOcCHSwxRNeW7jtGRUD2+Z3SdNE3A+pPiNT8WgUJoUyOwJmUr5t+JA2W15P78C/N+8RXrOA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -482,53 +504,53 @@ } }, "node_modules/@prisma/debug": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.5.0.tgz", - "integrity": "sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.7.0.tgz", + "integrity": "sha512-RabHn9emKoYFsv99RLxvfG2GHzWk2ZI1BuVzqYtmMSIcuGboHY5uFt3Q3boOREM9de6z5s3bQoyKeWnq8Fz22w==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.5.0.tgz", - "integrity": "sha512-FVPQYHgOllJklN9DUyujXvh3hFJCY0NX86sDmBErLvoZjy2OXGiZ5FNf3J/C4/RZZmCypZBYpBKEhx7b7rEsdw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.7.0.tgz", + "integrity": "sha512-3wDMesnOxPrOsq++e5oKV9LmIiEazFTRFZrlULDQ8fxdub5w4NgRBoxtWbvXmj2nJVCnzuz6eFix3OhIqsZ1jw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.5.0", - "@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", - "@prisma/fetch-engine": "6.5.0", - "@prisma/get-platform": "6.5.0" + "@prisma/debug": "6.7.0", + "@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed", + "@prisma/fetch-engine": "6.7.0", + "@prisma/get-platform": "6.7.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60.tgz", - "integrity": "sha512-iK3EmiVGFDCmXjSpdsKGNqy9hOdLnvYBrJB61far/oP03hlIxrb04OWmDjNTwtmZ3UZdA5MCvI+f+3k2jPTflQ==", + "version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed.tgz", + "integrity": "sha512-EvpOFEWf1KkJpDsBCrih0kg3HdHuaCnXmMn7XFPObpFTzagK1N0Q0FMnYPsEhvARfANP5Ok11QyoTIRA2hgJTA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.5.0.tgz", - "integrity": "sha512-3LhYA+FXP6pqY8FLHCjewyE8pGXXJ7BxZw2rhPq+CZAhvflVzq4K8Qly3OrmOkn6wGlz79nyLQdknyCG2HBTuA==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.7.0.tgz", + "integrity": "sha512-zLlAGnrkmioPKJR4Yf7NfW3hftcvqeNNEHleMZK9yX7RZSkhmxacAYyfGsCcqRt47jiZ7RKdgE0Wh2fWnm7WsQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.5.0", - "@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", - "@prisma/get-platform": "6.5.0" + "@prisma/debug": "6.7.0", + "@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed", + "@prisma/get-platform": "6.7.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.5.0.tgz", - "integrity": "sha512-xYcvyJwNMg2eDptBYFqFLUCfgi+wZLcj6HDMsj0Qw0irvauG4IKmkbywnqwok0B+k+W+p+jThM2DKTSmoPCkzw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.7.0.tgz", + "integrity": "sha512-i9IH5lO4fQwnMLvQLYNdgVh9TK3PuWBfQd7QLk/YurnAIg+VeADcZDbmhAi4XBBDD+hDif9hrKyASu0hbjwabw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.5.0" + "@prisma/debug": "6.7.0" } }, "node_modules/@types/body-parser": { @@ -638,6 +660,12 @@ "@types/send": "*" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -651,6 +679,50 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -665,6 +737,26 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -675,9 +767,22 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -719,7 +824,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -739,6 +843,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -802,13 +912,36 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -867,6 +1000,12 @@ "ms": "2.0.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -886,6 +1025,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -912,12 +1060,27 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1146,6 +1309,36 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1170,6 +1363,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1220,6 +1434,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1267,6 +1502,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1295,6 +1536,42 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1314,6 +1591,17 @@ "dev": true, "license": "ISC" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1352,6 +1640,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1375,6 +1672,121 @@ "node": ">=0.12.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1448,7 +1860,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1457,6 +1868,52 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1472,6 +1929,32 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", @@ -1526,6 +2009,21 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1536,6 +2034,19 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1569,6 +2080,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1578,6 +2098,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -1598,15 +2127,15 @@ } }, "node_modules/prisma": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.5.0.tgz", - "integrity": "sha512-yUGXmWqv5F4PByMSNbYFxke/WbnyTLjnJ5bKr8fLkcnY7U5rU9rUTh/+Fja+gOrRxEgtCbCtca94IeITj4j/pg==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.7.0.tgz", + "integrity": "sha512-vArg+4UqnQ13CVhc2WUosemwh6hr6cr6FY2uzDvCIFwH8pu8BXVv38PktoMLVjtX7sbYThxbnZF5YiR8sN2clw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.5.0", - "@prisma/engines": "6.5.0" + "@prisma/config": "6.7.0", + "@prisma/engines": "6.7.0" }, "bin": { "prisma": "build/index.js" @@ -1685,6 +2214,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1708,6 +2251,22 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1738,7 +2297,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1801,6 +2359,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1879,6 +2443,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1901,6 +2471,41 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1914,6 +2519,23 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1946,6 +2568,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -2016,6 +2644,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2033,6 +2667,43 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/package.json b/package.json index f7537bba..0aa76a93 100644 --- a/package.json +++ b/package.json @@ -12,21 +12,19 @@ "license": "ISC", "description": "", "dependencies": { - "@prisma/client": "^6.5.0", + "@prisma/client": "^6.7.0", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "@types/express": "^5.0.1", "@types/node": "^22.13.10", "nodemon": "^3.1.9", - "prisma": "^6.5.0", + "prisma": "^6.7.0", "tsx": "^4.19.3", "typescript": "^5.8.2" - }, - "prisma": { - "schema": "./src/db/prisma/schema.prisma", - "seed": "node ./src/db/prisma/seed.js" } } diff --git a/src/db/prisma/client.prisma.js b/prisma/client.prisma.js similarity index 100% rename from src/db/prisma/client.prisma.js rename to prisma/client.prisma.js diff --git a/src/db/prisma/migrations/20250318131126_add_first_table/migration.sql b/prisma/migrations/20250318131126_add_first_table/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250318131126_add_first_table/migration.sql rename to prisma/migrations/20250318131126_add_first_table/migration.sql diff --git a/src/db/prisma/migrations/20250318145107_add_product_rel/migration.sql b/prisma/migrations/20250318145107_add_product_rel/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250318145107_add_product_rel/migration.sql rename to prisma/migrations/20250318145107_add_product_rel/migration.sql diff --git a/src/db/prisma/migrations/20250319010554_remove_user_table/migration.sql b/prisma/migrations/20250319010554_remove_user_table/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250319010554_remove_user_table/migration.sql rename to prisma/migrations/20250319010554_remove_user_table/migration.sql diff --git a/src/db/prisma/migrations/20250320094435_add_tag_tags/migration.sql b/prisma/migrations/20250320094435_add_tag_tags/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250320094435_add_tag_tags/migration.sql rename to prisma/migrations/20250320094435_add_tag_tags/migration.sql diff --git a/src/db/prisma/migrations/20250320094633_modify_product_table/migration.sql b/prisma/migrations/20250320094633_modify_product_table/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250320094633_modify_product_table/migration.sql rename to prisma/migrations/20250320094633_modify_product_table/migration.sql diff --git a/src/db/prisma/migrations/20250320171240_add_product_tag_on_delete_cascade/migration.sql b/prisma/migrations/20250320171240_add_product_tag_on_delete_cascade/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250320171240_add_product_tag_on_delete_cascade/migration.sql rename to prisma/migrations/20250320171240_add_product_tag_on_delete_cascade/migration.sql diff --git a/src/db/prisma/migrations/20250321001436_add_tag_name_unique/migration.sql b/prisma/migrations/20250321001436_add_tag_name_unique/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250321001436_add_tag_name_unique/migration.sql rename to prisma/migrations/20250321001436_add_tag_name_unique/migration.sql diff --git a/src/db/prisma/migrations/20250321003748_change_on_delete_attribute/migration.sql b/prisma/migrations/20250321003748_change_on_delete_attribute/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250321003748_change_on_delete_attribute/migration.sql rename to prisma/migrations/20250321003748_change_on_delete_attribute/migration.sql diff --git a/src/db/prisma/migrations/20250321135508_add_user/migration.sql b/prisma/migrations/20250321135508_add_user/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250321135508_add_user/migration.sql rename to prisma/migrations/20250321135508_add_user/migration.sql diff --git a/src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql b/prisma/migrations/20250417150745_modify_article_table/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250417150745_modify_article_table/migration.sql rename to prisma/migrations/20250417150745_modify_article_table/migration.sql diff --git a/src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql b/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql similarity index 100% rename from src/db/prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql rename to prisma/migrations/20250418155732_add_on_delete_cascade/migration.sql diff --git a/prisma/migrations/20250509082904_add_user_field/migration.sql b/prisma/migrations/20250509082904_add_user_field/migration.sql new file mode 100644 index 00000000..5c58b607 --- /dev/null +++ b/prisma/migrations/20250509082904_add_user_field/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `encryptedPassword` on the `User` table. All the data in the column will be lost. + - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "encryptedPassword", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "image" TEXT, +ADD COLUMN "password" TEXT NOT NULL, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/prisma/migrations/20250509114208_add_nickname_attribute/migration.sql b/prisma/migrations/20250509114208_add_nickname_attribute/migration.sql new file mode 100644 index 00000000..bb79ad33 --- /dev/null +++ b/prisma/migrations/20250509114208_add_nickname_attribute/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[nickname]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "User_nickname_key" ON "User"("nickname"); diff --git a/prisma/migrations/20250512040355_add_like_model/migration.sql b/prisma/migrations/20250512040355_add_like_model/migration.sql new file mode 100644 index 00000000..fe4fb065 --- /dev/null +++ b/prisma/migrations/20250512040355_add_like_model/migration.sql @@ -0,0 +1,34 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,productId]` on the table `ProductLike` will be added. If there are existing duplicate values, this will fail. + - Added the required column `userId` to the `ProductLike` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ProductLike" ADD COLUMN "userId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "ArticleLike" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "articleId" INTEGER NOT NULL, + + CONSTRAINT "ArticleLike_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ArticleLike_userId_articleId_key" ON "ArticleLike"("userId", "articleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductLike_userId_productId_key" ON "ProductLike"("userId", "productId"); + +-- AddForeignKey +ALTER TABLE "ProductLike" ADD CONSTRAINT "ProductLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleLike" ADD CONSTRAINT "ArticleLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleLike" ADD CONSTRAINT "ArticleLike_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/db/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml similarity index 100% rename from src/db/prisma/migrations/migration_lock.toml rename to prisma/migrations/migration_lock.toml diff --git a/src/db/prisma/mock/articleMock.js b/prisma/mock/articleMock.js similarity index 100% rename from src/db/prisma/mock/articleMock.js rename to prisma/mock/articleMock.js diff --git a/src/db/prisma/mock/productMock.js b/prisma/mock/productMock.js similarity index 100% rename from src/db/prisma/mock/productMock.js rename to prisma/mock/productMock.js diff --git a/src/db/prisma/schema.prisma b/prisma/schema.prisma similarity index 67% rename from src/db/prisma/schema.prisma rename to prisma/schema.prisma index c66b667e..7bdc04c7 100644 --- a/src/db/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,15 +9,19 @@ datasource db { // 사용자 model User { - id String @id @default(uuid()) - email String @unique - encryptedPassword String - nickname String - // products Product[] - // productLikes ProductLike[] - // productComments ProductComment[] - // articles Article[] - // articleComments ArticleComment[] + id String @id @default(uuid()) + email String @unique + password String + nickname String @unique + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + // products Product[] + // productComments ProductComment[] + productLikes ProductLike[] + // articles Article[] + // articleComments ArticleComment[] + articleLikes ArticleLike[] } // 상품 @@ -28,13 +32,14 @@ model Product { price Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // user User @relation(fields: [userId], references: [id], onDelete: SetNull) + // user User @relation(fields: [userId], references: [id], onDelete: Cascade) // userId String productLikes ProductLike[] productComments ProductComment[] productTags ProductTag[] } +// 태그 model Tag { id Int @id @default(autoincrement()) name String @unique @@ -56,20 +61,22 @@ model ProductComment { id Int @id @default(autoincrement()) content String createdAt DateTime @default(now()) - // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) + // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) // authorId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId Int } -// 상품 좋아요(중고마켓 & 유저 연결) +// 상품 좋아요 model ProductLike { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - // user User @relation(fields: [userId], references: [id], onDelete: SetNull) - // userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId Int + + @@unique([userId, productId]) } // 자유 게시판 @@ -79,9 +86,10 @@ model Article { content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) + // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) // authorId String articleComments ArticleComment[] + articleLikes ArticleLike[] } // 자유 게시판 댓글 @@ -89,8 +97,20 @@ model ArticleComment { id Int @id @default(autoincrement()) content String createdAt DateTime @default(now()) - // author User @relation(fields: [authorId], references: [id], onDelete: SetNull) + // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) // authorId String article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) articleId Int } + +// 자유 게시판 좋아요 +model ArticleLike { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + userId String + article Article @relation(fields: [articleId], references: [id]) + articleId Int + + @@unique([userId, articleId]) +} diff --git a/src/db/prisma/seed.js b/prisma/seed.js similarity index 100% rename from src/db/prisma/seed.js rename to prisma/seed.js diff --git a/src/app.js b/src/app.js index 7ae2bdc4..09e59bd2 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,7 @@ import express from "express"; import cors from "cors"; import "dotenv/config"; -import handleError from "./middlewares/handleErrorMiddleware.js"; +import errorHandler from "./middlewares/errorHandler.js"; import router from "./routes/indexRoutes.js"; const PORT = process.env.PORT || 3000; @@ -17,6 +17,7 @@ app.use( "https://been-panda.vercel.app", "https://been-panda.onrender.com", "http://localhost:3000", + "http://localhost:3001", "http://127.0.0.1:3000", "http://localhost:5173", "http://127.0.0.1:5173", @@ -28,7 +29,7 @@ app.use( app.use(router); // 4. 에러 미들웨어 등록 -app.use(handleError); +app.use(errorHandler); // 5. 서버 연결 app.listen(PORT, () => { diff --git a/src/controllers/articleController.js b/src/controllers/articleController.js new file mode 100644 index 00000000..3c123e8f --- /dev/null +++ b/src/controllers/articleController.js @@ -0,0 +1,73 @@ +import articleService from "../services/articleService.js"; + +// 게시글 목록 불러오기 +const getArticles = async (req, res, next) => { + try { + const [articles, totalCount] = await articleService.getArticles(req.query); + + res.status(200).json({ list: articles, totalCount }); + } catch (e) { + next(e); + } +}; + +// 게시글 상세조회 +const getArticle = async (req, res, next) => { + const articleId = Number(req.params.articleId); + + try { + const article = await articleService.getArticle(articleId); + + res.status(200).json(article); + } catch (e) { + next(e); + } +}; + +// 게시글 작성 +const createArticle = async (req, res, next) => { + try { + const newArticle = await articleService.createArticle(req.body); + + res.status(201).json(newArticle); + } catch (e) { + next(e); + } +}; + +// 게시글 수정 +const updateArticle = async (req, res, next) => { + const articleId = Number(req.params.articleId); + + try { + const updatedArticle = await articleService.updateArticle( + articleId, + req.body + ); + + res.status(200).json(updatedArticle); + } catch (e) { + next(e); + } +}; + +// 게시글 삭제 +const deleteArticle = async (req, res, next) => { + const articleId = Number(req.params.articleId); + + try { + await articleService.deleteArticle(articleId); + + res.sendStatus(204); + } catch (e) { + next(e); + } +}; + +export default { + getArticles, + getArticle, + createArticle, + updateArticle, + deleteArticle, +}; diff --git a/src/controllers/productController.js b/src/controllers/productController.js new file mode 100644 index 00000000..67708d20 --- /dev/null +++ b/src/controllers/productController.js @@ -0,0 +1,73 @@ +import productService from "../services/productService.js"; + +// 상품 목록 불러오기 +const getProducts = async (req, res, next) => { + try { + const [products, totalCount] = await productService.getProducts(req.query); + + res.status(200).json({ list: products, totalCount }); + } catch (e) { + next(e); + } +}; + +// 상품 상세조회 +const getProduct = async (req, res, next) => { + const productId = Number(req.params.productId); + + try { + const product = await productService.getProduct(productId); + + res.status(200).json(product); + } catch (e) { + next(e); + } +}; + +// 상품 등록 +const createProduct = async (req, res, next) => { + try { + const newProduct = await productService.createProduct(req.body); + + res.status(201).json(newProduct); + } catch (e) { + next(e); + } +}; + +// 상품 수정 +const updateProduct = async (req, res, next) => { + const productId = Number(req.params.productId); + + try { + const updatedProduct = await productService.updateProduct( + productId, + req.body + ); + + res.status(200).json(updatedProduct); + } catch (e) { + next(e); + } +}; + +// 상품 삭제 +const deleteProduct = async (req, res, next) => { + const productId = Number(req.params.productId); + + try { + await productService.deleteProduct(productId); + + res.sendStatus(204); + } catch (e) { + next(e); + } +}; + +export default { + getProducts, + getProduct, + createProduct, + updateProduct, + deleteProduct, +}; diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js new file mode 100644 index 00000000..514464dd --- /dev/null +++ b/src/middlewares/errorHandler.js @@ -0,0 +1,11 @@ +export default function errorHandler(err, req, res, next) { + const status = err.code ?? 500; + + return res.status(status).json({ + path: req.path, + method: req.method, + message: err.message ?? "서버 오류입니다.", + data: err.data ?? undefined, + date: new Date(), + }); +} diff --git a/src/middlewares/handleErrorMiddleware.js b/src/middlewares/handleErrorMiddleware.js deleted file mode 100644 index 9a0f5d40..00000000 --- a/src/middlewares/handleErrorMiddleware.js +++ /dev/null @@ -1,25 +0,0 @@ -function handleError(err, req, res, next) { - switch (err.name) { - case "ValidationError": - res.status(400).send({ message: "유효성 검증 실패하였습니다." }); - break; - case "CastError": - res.status(400).send({ message: "잘못된 데이터가 입력되었습니다." }); - break; - case "ReferenceError": - res.status(500).send({ message: "참조할 수 없습니다." }); - break; - default: - res.send({ message: err.message }); - break; - } -} - -// 개발용 에러 미들웨어 -// function handleError(err, req, res, next) { -// const message = err.message; - -// res.send(message); -// } - -export default handleError; diff --git a/src/middlewares/requiredDataValidate.js b/src/middlewares/requiredDataValidate.js new file mode 100644 index 00000000..39f7efd5 --- /dev/null +++ b/src/middlewares/requiredDataValidate.js @@ -0,0 +1,46 @@ +export default function requiredDataValidate(req, res, next) { + try { + const { name, description, price, tags } = req.body; + + if (!name || !description || !price || !tags) { + const error = new Error("필수 항목을 모두 입력해주세요."); + error.code = 400; + + throw error; + } + + if (10 < name.length) { + const error = new Error("이름은 10글자 이내로 입력해주세요."); + error.code = 400; + + throw error; + } + + if (10 > description.length || 100 < description.length) { + const error = new Error("설명은 10 ~ 100글자 이내로 입력해주세요."); + error.code = 400; + + throw error; + } + + if (!+price) { + const error = new Error("가격은 숫자만 입력해주세요."); + error.code = 400; + + throw error; + } + + tags.map((tag) => { + if (Boolean(5 < tag.length)) { + const error = new Error("태그는 5글자 이내로 입력해주세요."); + error.code = 400; + + throw error; + } + }); + + next(); + } catch (e) { + next(e); + } +} diff --git a/src/repositories/articleRepository.js b/src/repositories/articleRepository.js new file mode 100644 index 00000000..30184816 --- /dev/null +++ b/src/repositories/articleRepository.js @@ -0,0 +1,65 @@ +import prisma from "../../prisma/client.prisma.js"; + +const findAll = (query) => { + const { offset, limit, orderBy, keyword } = query; + const filter = { + OR: [ + { title: { contains: keyword || "", mode: "insensitive" } }, + { content: { contains: keyword || "", mode: "insensitive" } }, + ], + }; + + return Promise.all([ + prisma.article.findMany({ + where: filter, + skip: (Number(offset) - 1) * Number(limit) || 0, + take: Number(limit) || 10, + orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, + omit: { updatedAt: true }, + }), + prisma.article.count({ where: filter }), + ]); +}; + +const findById = (articleId) => { + return prisma.article.findUnique({ + where: { id: articleId }, + omit: { updatedAt: true }, + }); +}; + +const findByIdWithTx = (tx, articleId) => { + return tx.article.findUnique({ + where: { id: articleId }, + }); +}; + +const create = (body) => { + return prisma.article.create({ + data: body, + }); +}; + +const updateWithTx = (tx, articleId, body) => { + const { title, content } = body; + + return tx.article.update({ + where: { id: articleId }, + data: { title, content }, + }); +}; + +const deleteWithTx = (tx, articleId) => { + return tx.article.delete({ + where: { id: articleId }, + }); +}; + +export default { + findAll, + findById, + findByIdWithTx, + create, + updateWithTx, + deleteWithTx, +}; diff --git a/src/repositories/productRepository.js b/src/repositories/productRepository.js new file mode 100644 index 00000000..79c54748 --- /dev/null +++ b/src/repositories/productRepository.js @@ -0,0 +1,86 @@ +import prisma from "../../prisma/client.prisma.js"; + +const findAll = (query) => { + const { offset, limit, orderBy, keyword } = query; + const filter = { + OR: [ + { name: { contains: keyword || "", mode: "insensitive" } }, + { description: { contains: keyword || "", mode: "insensitive" } }, + ], + }; + + return Promise.all([ + prisma.product.findMany({ + where: filter, + skip: (Number(offset) - 1) * Number(limit) || 0, + take: Number(limit) || 10, + orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, + omit: { description: true, updatedAt: true }, + }), + prisma.product.count({ where: filter }), + ]); +}; + +const findByIdWithTx = (tx, productId) => { + return tx.product.findUnique({ + where: { id: productId }, + omit: { updatedAt: true }, + }); +}; + +const findProductTagByIdWithTx = (tx, productId) => { + return tx.productTag.findMany({ + where: { productId }, + include: { tag: true }, + }); +}; + +const createWithTx = (tx, body) => { + const { name, description, price } = body; + + return tx.product.create({ + data: { name, description, price }, + }); +}; + +const findTagByNameWithTx = (tx, tagName) => { + return tx.tag.findUnique({ where: { name: tagName } }); +}; + +const createTagWithTx = (tx, tagName) => { + return tx.tag.create({ data: { name: tagName } }); +}; + +const createProductTagWithTx = (tx, productId, tagId) => { + return tx.productTag.create({ data: { productId, tagId } }); +}; + +const updateProductWithTx = (tx, productId, body) => { + const { name, description, price } = body; + + return tx.product.update({ + where: { id: productId }, + data: { name, description, price }, + }); +}; + +const deleteProductTagsWithTx = (tx, productId) => { + return tx.productTag.deleteMany({ where: { productId } }); +}; + +const deleteProductWithTx = (tx, productId) => { + return tx.product.delete({ where: { id: productId } }); +}; + +export default { + findAll, + findByIdWithTx, + findProductTagByIdWithTx, + createWithTx, + findTagByNameWithTx, + createTagWithTx, + createProductTagWithTx, + updateProductWithTx, + deleteProductTagsWithTx, + deleteProductWithTx, +}; diff --git a/src/routes/articleRoutes.js b/src/routes/articleRoutes.js index e1cc4620..722cd83d 100644 --- a/src/routes/articleRoutes.js +++ b/src/routes/articleRoutes.js @@ -1,108 +1,21 @@ import express from "express"; -import prisma from "../db/prisma/client.prisma.js"; +import articleController from "../controllers/articleController.js"; const articleRouter = express.Router(); // 게시글 목록 불러오기 -articleRouter.get("/", async (req, res, next) => { - try { - const { offset, limit, orderBy, keyword } = req.query; - const filter = { - OR: [ - { title: { contains: keyword || "", mode: "insensitive" } }, - { content: { contains: keyword || "", mode: "insensitive" } }, - ], - }; - - const articles = await prisma.article.findMany({ - where: filter, - skip: (Number(offset) - 1) * Number(limit) || 0, - take: Number(limit) || 10, - orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, - omit: { updatedAt: true }, - }); - - const totalCount = await prisma.article.count({ where: filter }); - - res.json({ list: articles, totalCount }); - } catch (e) { - next(e); - } -}); +articleRouter.get("/", articleController.getArticles); // 게시글 상세조회 -articleRouter.get("/:articleId", async (req, res, next) => { - try { - const articleId = Number(req.params.articleId); - - const article = await prisma.article.findUnique({ - where: { id: articleId }, - omit: { updatedAt: true }, - }); - if (!article) throw new Error("해당 게시글을 찾을 수 없습니다."); - - res.json(article); - } catch (e) { - next(e); - } -}); +articleRouter.get("/:articleId", articleController.getArticle); // 게시글 작성 -articleRouter.post("/", async (req, res, next) => { - try { - const { title, content } = req.body; - if (!title) throw new Error("제목을 입력해주세요."); - if (!content) throw new Error("내용을 입력해주세요."); - - const newArticle = await prisma.article.create({ - data: { title, content }, - }); - - res.status(201).json(newArticle); - } catch (e) { - next(e); - } -}); +articleRouter.post("/", articleController.createArticle); // 게시글 수정 -articleRouter.patch("/:articleId", async (req, res, next) => { - try { - const { title, content } = req.body; - const articleId = Number(req.params.articleId); - if (!(title || content)) throw new Error("수정할 내용을 입력해주세요."); - - await prisma.$transaction(async (tx) => { - const article = await tx.article.findUnique({ where: { id: articleId } }); - if (!article) throw new Error("게시글을 찾을 수 없습니다."); - - const updateArticle = await tx.article.update({ - where: { id: articleId }, - data: { title, content }, - }); - - res.status(200).json(updateArticle); - }); - } catch (e) { - next(e); - } -}); +articleRouter.patch("/:articleId", articleController.updateArticle); // 게시글 삭제 -articleRouter.delete("/:articleId", async (req, res, next) => { - try { - const articleId = Number(req.params.articleId); - - await prisma.$transaction(async (tx) => { - const article = await tx.article.findUnique({ where: { id: articleId } }); - if (!article) throw new Error("이미 삭제된 게시글 입니다."); - - await prisma.article.delete({ where: { id: articleId } }); - - res.sendStatus(204); - }); - } catch (e) { - next(e); - } -}); +articleRouter.delete("/:articleId", articleController.deleteArticle); export default articleRouter; diff --git a/src/routes/commentRoutes.js b/src/routes/commentRoutes.js index 08db09a9..547731a6 100644 --- a/src/routes/commentRoutes.js +++ b/src/routes/commentRoutes.js @@ -1,5 +1,5 @@ import express from "express"; -import prisma from "../db/prisma/client.prisma.js"; +import prisma from "../../prisma/client.prisma.js"; const commentRouter = express.Router(); diff --git a/src/routes/indexRoutes.js b/src/routes/indexRoutes.js index 5e085c05..b9308968 100644 --- a/src/routes/indexRoutes.js +++ b/src/routes/indexRoutes.js @@ -6,7 +6,7 @@ import commentRouter from "./commentRoutes.js"; const router = express.Router(); -router.use("/users", userRouter); +router.use("/auth", userRouter); router.use("/products", productRouter); router.use("/articles", articleRouter); router.use("/", commentRouter); diff --git a/src/routes/productRoutes.js b/src/routes/productRoutes.js index c26c9b32..80a5017e 100644 --- a/src/routes/productRoutes.js +++ b/src/routes/productRoutes.js @@ -1,147 +1,26 @@ import express from "express"; -import prisma from "../db/prisma/client.prisma.js"; +import requiredDataValidate from "../middlewares/requiredDataValidate.js"; +import productController from "../controllers/productController.js"; const productRouter = express.Router(); // 상품 목록 불러오기 -productRouter.get("/", async (req, res, next) => { - try { - const { offset, limit, orderBy, keyword } = req.query; - const filter = { - OR: [ - { name: { contains: keyword || "", mode: "insensitive" } }, - { description: { contains: keyword || "", mode: "insensitive" } }, - ], - }; - - await prisma.$transaction(async (tx) => { - const products = await tx.product.findMany({ - where: filter, - skip: (Number(offset) - 1) * Number(limit) || 0, - take: Number(limit) || 10, - orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, - omit: { description: true, updatedAt: true }, - }); - - const totalCount = await tx.product.count({ where: filter }); - - res.json({ list: products, totalCount }); - }); - } catch (e) { - next(e); - } -}); +productRouter.get("/", productController.getProducts); // 상품 상세조회 -productRouter.get("/:productId", async (req, res, next) => { - try { - const productId = Number(req.params.productId); - - await prisma.$transaction(async (tx) => { - const product = await tx.product.findUnique({ - where: { id: productId }, - omit: { updatedAt: true }, - }); - if (!product) throw new Error("상품을 찾을 수 없습니다."); - - const productTag = await tx.productTag.findMany({ - where: { productId }, - include: { tag: true }, - }); - - const tags = productTag.map((tag) => tag.tag.name); - - res.json({ ...product, tags }); - }); - } catch (e) { - next(e); - } -}); +productRouter.get("/:productId", productController.getProduct); // 상품 등록 -productRouter.post("/", async (req, res, next) => { - try { - const { name, description, price, tags } = req.body; - if (10 < name.length) throw new Error("10글자 이내로 입력해주세요."); - if (10 > description.length || 100 < description.length) - throw new Error("10 ~ 100글자 이내로 입력해주세요."); - tags.map((tag) => { - if (Boolean(5 < tag.length)) - throw new Error("5글자 이내로 입력해주세요."); - }); - - await prisma.$transaction(async (tx) => { - const newProduct = await tx.product.create({ - data: { name, description, price }, - }); - - const newTags = await Promise.all( - tags.map(async (tagName) => { - let tag = await tx.tag.findUnique({ where: { name: tagName } }); - if (!tag) tag = await tx.tag.create({ data: { name: tagName } }); - - await tx.productTag.create({ - data: { productId: newProduct.id, tagId: tag.id }, - }); - return tag.name; - }) - ); - - res.status(201).json({ ...newProduct, tags: newTags }); - }); - } catch (e) { - next(e); - } -}); +productRouter.post("/", requiredDataValidate, productController.createProduct); // 상품 수정 -productRouter.patch("/:productId", async (req, res, next) => { - try { - const { name, description, price, tags } = req.body; - const productId = Number(req.params.productId); - if (!(name || description || price || tags)) - throw new Error("수정할 내용을 입력해주세요."); - - await prisma.$transaction(async (tx) => { - const updateProduct = await tx.product.update({ - where: { id: productId }, - data: { name, description, price }, - }); - - await tx.productTag.deleteMany({ where: { productId } }); - - const updateTags = await Promise.all( - tags.map(async (tagName) => { - let tag = await tx.tag.findUnique({ where: { name: tagName } }); - if (!tag) tag = await tx.tag.create({ data: { name: tagName } }); - await tx.productTag.create({ data: { productId, tagId: tag.id } }); - return tag.name; - }) - ); - - res.status(200).json({ ...updateProduct, tags: updateTags }); - }); - } catch (e) { - next(e); - } -}); +productRouter.patch("/:productId", productController.updateProduct); // 상품 삭제 -productRouter.delete("/:productId", async (req, res, next) => { - try { - const productId = Number(req.params.productId); - - await prisma.$transaction(async (tx) => { - const product = await tx.product.findUnique({ where: { id: productId } }); - if (!product) throw new Error("이미 삭제된 상품입니다."); +productRouter.delete("/:productId", productController.deleteProduct); - await tx.product.delete({ where: { id: productId } }); +// 상품 좋아요 - res.sendStatus(204); - }); - } catch (e) { - next(e); - } -}); +// 상품 좋아요 취소 export default productRouter; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 7854d97e..deea650e 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -1,18 +1,73 @@ import express from "express"; -import prisma from "../db/prisma/client.prisma.js"; +import prisma from "../../prisma/client.prisma.js"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; const userRouter = express.Router(); +// 비밀번호 제외 +function filterPassword(user) { + const { password, ...data } = user; + + return data; +} + +// JWT 토큰 발급 +function createToken(user, type = "accessToken") { + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: type === "accessToken" ? "30m" : "1day", + }); + + return token; +} + // 회원가입 -userRouter.post("/signup", async (req, res, next) => { +userRouter.post("/signUp", async (req, res, next) => { try { const { email, password, nickname } = req.body; + // 유효성 검사 실패 + if (!email || !password || !nickname) { + const error = new Error("이메일, 비밀번호, 닉네임을 모두 입력해주세요."); + error.code = 400; + + throw error; + } + + const existedUser = await prisma.user.findUnique({ + where: { email }, + }); + + // 존재하는 이메일 + if (existedUser) { + const error = new Error("이미 존재하는 이메일입니다."); + error.code = 400; + + throw error; + } + + const existedNickname = await prisma.user.findUnique({ + where: { nickname }, + }); + + // 존재하는 닉네임 + if (existedNickname) { + const error = new Error("이미 존재하는 닉네임입니다."); + error.code = 400; + + throw error; + } + + const encryptedPassword = await bcrypt.hash(password, 10); const user = await prisma.user.create({ - data: { email, encryptedPassword: password, nickname }, + data: { email, password: encryptedPassword, nickname }, }); - res.status(201).json(user); + const filterPasswordUser = filterPassword(user); + const accessToken = createToken(user); + const refreshToken = createToken(user, "refreshToken"); + + res.status(201).json({ ...filterPasswordUser, accessToken, refreshToken }); } catch (e) { next(e); } @@ -23,16 +78,33 @@ userRouter.post("/login", async (req, res, next) => { try { const { email, password } = req.body; - await prisma.$transaction(async (tx) => { - const user = await tx.user.findUnique({ - where: { email, encryptedPassword: password }, - }); - if (!user) throw new Error("존재하지 않는 사용자입니다."); + const user = await prisma.user.findUnique({ + where: { email }, + }); - const token = { accessToken: `@${user.id}@` }; + // 이메일 불일치 + if (!user) { + const error = new Error("존재하지 않는 이메일입니다."); + error.code = 400; - res.status(200).json(token); - }); + throw error; + } + + // 비밀번호 불일치 + const verifyPassword = await bcrypt.compare(password, user.password); + + if (!verifyPassword) { + const error = new Error("비밀번호가 일치하지 않습니다."); + error.code = 400; + + throw error; + } + + const filterPasswordUser = filterPassword(user); + const accessToken = createToken(user); + const refreshToken = createToken(user, "refreshToken"); + + res.status(200).json({ ...filterPasswordUser, accessToken, refreshToken }); } catch (e) { next(e); } diff --git a/src/services/articleService.js b/src/services/articleService.js new file mode 100644 index 00000000..49bc2b8e --- /dev/null +++ b/src/services/articleService.js @@ -0,0 +1,93 @@ +import prisma from "../../prisma/client.prisma.js"; +import articleRepository from "../repositories/articleRepository.js"; + +// 게시글 목록 불러오기 +const getArticles = async (query) => { + const [articles, totalCount] = await articleRepository.findAll(query); + + if (!articles || articles.length === 0) { + const error = new Error("게시글이 없습니다."); + error.code = 404; + + throw error; + } + + return [articles, totalCount]; +}; + +// 게시글 상세조회 +const getArticle = async (articleId) => { + const article = await articleRepository.findById(articleId); + + if (!article) { + const error = new Error("해당 게시글을 찾을 수 없습니다."); + error.code = 404; + + throw error; + } + + return article; +}; + +// 게시글 작성 +const createArticle = (body) => { + const { title, content } = body; + + if (!title || !content) { + const error = new Error("필수 항목을 모두 입력해주세요."); + error.code = 400; + + throw error; + } + + return articleRepository.create(body); +}; + +// 게시글 수정 +const updateArticle = async (articleId, body) => { + const { title, content } = body; + + if (!(title || content)) { + const error = new Error("수정할 내용을 입력해주세요."); + error.code = 400; + + throw error; + } + + return await prisma.$transaction(async (tx) => { + const article = await articleRepository.findByIdWithTx(tx, articleId); + + if (!article) { + const error = new Error("해당 게시글을 찾을 수 없습니다."); + error.code = 404; + + throw error; + } + + return articleRepository.updateWithTx(tx, articleId, body); + }); +}; + +// 게시글 삭제 +const deleteArticle = async (articleId) => { + return await prisma.$transaction(async (tx) => { + const article = await articleRepository.findByIdWithTx(tx, articleId); + + if (!article) { + const error = new Error("이미 삭제된 게시글입니다."); + error.code = 404; + + throw error; + } + + return articleRepository.deleteWithTx(tx, articleId); + }); +}; + +export default { + getArticles, + getArticle, + createArticle, + updateArticle, + deleteArticle, +}; diff --git a/src/services/productService.js b/src/services/productService.js new file mode 100644 index 00000000..bf383164 --- /dev/null +++ b/src/services/productService.js @@ -0,0 +1,137 @@ +import prisma from "../../prisma/client.prisma.js"; +import productRepository from "../repositories/productRepository.js"; + +// 상품 목록 불러오기 +const getProducts = async (query) => { + const [products, totalCount] = await productRepository.findAll(query); + + if (!products || products.length === 0) { + const error = new Error("상품이 없습니다."); + error.code = 404; + + throw error; + } + + return [products, totalCount]; +}; + +// 상품 상세조회 +const getProduct = async (productId) => { + return await prisma.$transaction(async (tx) => { + const product = await productRepository.findByIdWithTx(tx, productId); + + if (!product) { + const error = new Error("존재하지 않는 상품입니다."); + error.code = 404; + + throw error; + } + + const productTag = await productRepository.findProductTagByIdWithTx( + tx, + productId + ); + + const tags = productTag.map((tag) => tag.tag.name); + + return { ...product, tags }; + }); +}; + +// 상품 등록 +const createProduct = async (body) => { + const { name, description, price, tags } = body; + + if (!name || !description || !price || !tags) { + const error = new Error("필수 항목을 모두 입력해주세요."); + error.code = 400; + + throw error; + } + + return await prisma.$transaction(async (tx) => { + const newProduct = await productRepository.createWithTx(tx, body); + + const newTags = await Promise.all( + tags.map(async (tagName) => { + let tag = await productRepository.findTagByNameWithTx(tx, tagName); + + if (!tag) { + tag = await productRepository.createTagWithTx(tx, tagName); + } + + await productRepository.createProductTagWithTx( + tx, + newProduct.id, + tag.id + ); + + return tag.name; + }) + ); + + return { ...newProduct, tags: newTags }; + }); +}; + +// 상품 수정 +const updateProduct = async (productId, body) => { + const { name, description, price, tags } = body; + + if (!(name || description || price || tags)) { + const error = new Error("수정할 내용을 입력해주세요."); + error.code = 400; + + throw error; + } + + return await prisma.$transaction(async (tx) => { + const updatedProduct = await productRepository.updateProductWithTx( + tx, + productId, + body + ); + + await productRepository.deleteProductTagsWithTx(tx, productId); + + const updatedTags = await Promise.all( + tags.map(async (tagName) => { + let tag = await productRepository.findTagByNameWithTx(tx, tagName); + + if (!tag) { + tag = await productRepository.createTagWithTx(tx, tagName); + } + + await productRepository.createProductTagWithTx(tx, productId, tag.id); + + return tag.name; + }) + ); + + return { ...updatedProduct, tags: updatedTags }; + }); +}; + +// 상품 삭제 +const deleteProduct = async (productId) => { + return await prisma.$transaction(async (tx) => { + const product = await productRepository.findByIdWithTx(tx, productId); + + if (!product) { + const error = new Error("이미 삭제된 상품입니다."); + error.code = 404; + + throw error; + } + + return productRepository.deleteProductWithTx(tx, productId); + }); +}; + +export default { + getProducts, + getProduct, + createProduct, + updateProduct, + deleteProduct, +}; diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 00000000..e69de29b From 4db897949157003c66748ff5d1f72ef986061880 Mon Sep 17 00:00:00 2001 From: Decal Date: Wed, 14 May 2025 10:20:20 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 190 +++++++++++++++++- package.json | 4 +- .../migration.sql | 19 ++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 47 +++-- src/app.js | 1 + src/controllers/articleController.js | 35 +++- src/controllers/productController.js | 52 ++++- src/middlewares/requiredDataValidate.js | 4 +- src/repositories/articleRepository.js | 27 ++- src/repositories/productRepository.js | 39 +++- src/routes/articleRoutes.js | 6 + src/routes/productRoutes.js | 13 +- src/services/articleService.js | 20 +- src/services/productService.js | 51 +++-- 15 files changed, 452 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20250513130827_add_product_image_model/migration.sql diff --git a/package-lock.json b/package-lock.json index 510353f2..1f204eae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" + "express-jwt": "^8.5.1", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.2" }, "devDependencies": { "@types/express": "^5.0.1", @@ -606,6 +608,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -613,11 +625,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -737,6 +754,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -849,6 +872,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -936,6 +976,51 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -978,6 +1063,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1260,6 +1351,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-jwt": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-8.5.1.tgz", + "integrity": "sha512-Dv6QjDLpR2jmdb8M6XQXiCcpEom7mK8TOqnr0/TngDKsG2DHVkO8+XnVxkJVN7BuS1I3OrGw6N8j5DaaGgkDRQ==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9", + "express-unless": "^2.1.3", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-unless": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz", + "integrity": "sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1672,6 +1783,12 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -1868,6 +1985,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -1920,6 +2046,36 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2155,6 +2311,12 @@ } } }, + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2471,6 +2633,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2607,6 +2777,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", @@ -2632,7 +2808,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -2699,6 +2874,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 0aa76a93..874b4416 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" + "express-jwt": "^8.5.1", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.2" }, "devDependencies": { "@types/express": "^5.0.1", diff --git a/prisma/migrations/20250513130827_add_product_image_model/migration.sql b/prisma/migrations/20250513130827_add_product_image_model/migration.sql new file mode 100644 index 00000000..3fc7e366 --- /dev/null +++ b/prisma/migrations/20250513130827_add_product_image_model/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" SERIAL NOT NULL, + "imageUrl" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "productId" INTEGER NOT NULL, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductImage_imageUrl_key" ON "ProductImage"("imageUrl"); + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57fd..044d57cd 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7bdc04c7..7f213b82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,19 +9,20 @@ datasource db { // 사용자 model User { - id String @id @default(uuid()) - email String @unique - password String - nickname String @unique - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + password String + nickname String @unique + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // products Product[] // productComments ProductComment[] - productLikes ProductLike[] + productLikes ProductLike[] // articles Article[] // articleComments ArticleComment[] - articleLikes ArticleLike[] + articleLikes ArticleLike[] + productImagea ProductImage[] } // 상품 @@ -37,6 +38,7 @@ model Product { productLikes ProductLike[] productComments ProductComment[] productTags ProductTag[] + productImages ProductImage[] } // 태그 @@ -48,10 +50,10 @@ model Tag { model ProductTag { id Int @id @default(autoincrement()) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId Int - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tagId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@unique([productId, tagId]) } @@ -60,25 +62,36 @@ model ProductTag { model ProductComment { id Int @id @default(autoincrement()) content String + productId Int createdAt DateTime @default(now()) // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) // authorId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId Int } // 상품 좋아요 model ProductLike { id Int @id @default(autoincrement()) + userId String + productId Int createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId Int @@unique([userId, productId]) } +// 상품 이미지 +model ProductImage { + id Int @id @default(autoincrement()) + imageUrl String @unique + userId String + productId Int + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + product Product @relation(fields: [productId], references: [id]) +} + // 자유 게시판 model Article { id Int @id @default(autoincrement()) @@ -97,20 +110,20 @@ model ArticleComment { id Int @id @default(autoincrement()) content String createdAt DateTime @default(now()) + articleId Int // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) // authorId String article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) - articleId Int } // 자유 게시판 좋아요 model ArticleLike { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) userId String - article Article @relation(fields: [articleId], references: [id]) articleId Int + user User @relation(fields: [userId], references: [id]) + article Article @relation(fields: [articleId], references: [id]) @@unique([userId, articleId]) } diff --git a/src/app.js b/src/app.js index 09e59bd2..c5e63b38 100644 --- a/src/app.js +++ b/src/app.js @@ -24,6 +24,7 @@ app.use( ], }) ); +app.use("/images", express.static("uploads")); // 3. routes 등록 app.use(router); diff --git a/src/controllers/articleController.js b/src/controllers/articleController.js index 3c123e8f..d9d4ef64 100644 --- a/src/controllers/articleController.js +++ b/src/controllers/articleController.js @@ -16,7 +16,7 @@ const getArticle = async (req, res, next) => { const articleId = Number(req.params.articleId); try { - const article = await articleService.getArticle(articleId); + const article = await articleService.getArticle(userId, articleId); res.status(200).json(article); } catch (e) { @@ -64,10 +64,43 @@ const deleteArticle = async (req, res, next) => { } }; +// TODO: 게시글 상세조회, 게시글 좋아요, 게시글 좋아요 취소에 user 인증 미들웨어 달고, req.auth로 들어오는 user정보에서 id를 추출한 다음에 userId를 넘겨주기 +// TODO: 게시글 좋아요 전체 갯수 알 수 있는 목록도 반환시키기 +// 게시글 좋아요 +const addlikeArticle = async (req, res, next) => { + const articleId = Number(req.params.articleId); + + try { + const like = await articleService.addlikeArticle(userId, articleId); + + res.status(200).json(like); + } catch (e) { + next(e); + } +}; + +// 게시글 좋아요 취소 +const cancelLikeArticle = async (req, res, next) => { + const articleId = Number(req.params.articleId); + + try { + const cancelLike = await articleService.cancelLikeArticle( + userId, + articleId + ); + + res.status(200).json(cancelLike); + } catch (e) { + next(e); + } +}; + export default { getArticles, getArticle, createArticle, updateArticle, deleteArticle, + addlikeArticle, + cancelLikeArticle, }; diff --git a/src/controllers/productController.js b/src/controllers/productController.js index 67708d20..7eb51fe5 100644 --- a/src/controllers/productController.js +++ b/src/controllers/productController.js @@ -14,11 +14,16 @@ const getProducts = async (req, res, next) => { // 상품 상세조회 const getProduct = async (req, res, next) => { const productId = Number(req.params.productId); + const baseUrl = `${req.protocol}://${req.get("host")}/images`; try { - const product = await productService.getProduct(productId); + const product = await productService.getProduct(userId, productId); - res.status(200).json(product); + const imageUrls = product.images.map( + (imageUrl) => `${baseUrl}/${imageUrl}` + ); + + res.status(200).json({ ...product, images: imageUrls }); } catch (e) { next(e); } @@ -26,10 +31,17 @@ const getProduct = async (req, res, next) => { // 상품 등록 const createProduct = async (req, res, next) => { + const images = req.files; + const baseUrl = `${req.protocol}://${req.get("host")}/images`; + try { - const newProduct = await productService.createProduct(req.body); + const newProduct = await productService.createProduct(req.body, images); - res.status(201).json(newProduct); + const imageUrls = newProduct.images.map( + (imageUrl) => `${baseUrl}/${imageUrl}` + ); + + res.status(201).json({ ...newProduct, images: imageUrls }); } catch (e) { next(e); } @@ -64,10 +76,42 @@ const deleteProduct = async (req, res, next) => { } }; +// TODO: 상품 상세조회, 상품 좋아요, 상품 좋아요 취소에 user 인증 미들웨어 달고, req.auth로 들어오는 user정보에서 id를 추출한 다음에 userId를 넘겨주기 +// 상품 좋아요 +const addlikeProduct = async (req, res, next) => { + const productId = Number(req.params.productId); + + try { + const like = await productService.addlikeProduct(userId, productId); + + res.status(200).json(like); + } catch (e) { + next(e); + } +}; + +// 상품 좋아요 취소 +const cancelLikeProduct = async (req, res, next) => { + const productId = Number(req.params.productId); + + try { + const cancelLike = await productService.cancelLikeProduct( + userId, + productId + ); + + res.status(200).json(cancelLike); + } catch (e) { + next(e); + } +}; + export default { getProducts, getProduct, createProduct, updateProduct, deleteProduct, + addlikeProduct, + cancelLikeProduct, }; diff --git a/src/middlewares/requiredDataValidate.js b/src/middlewares/requiredDataValidate.js index 39f7efd5..2c2c34a1 100644 --- a/src/middlewares/requiredDataValidate.js +++ b/src/middlewares/requiredDataValidate.js @@ -23,14 +23,14 @@ export default function requiredDataValidate(req, res, next) { throw error; } - if (!+price) { + if (typeof Number(price) !== "number") { const error = new Error("가격은 숫자만 입력해주세요."); error.code = 400; throw error; } - tags.map((tag) => { + JSON.parse(tags).map((tag) => { if (Boolean(5 < tag.length)) { const error = new Error("태그는 5글자 이내로 입력해주세요."); error.code = 400; diff --git a/src/repositories/articleRepository.js b/src/repositories/articleRepository.js index 30184816..695f60e8 100644 --- a/src/repositories/articleRepository.js +++ b/src/repositories/articleRepository.js @@ -21,11 +21,16 @@ const findAll = (query) => { ]); }; -const findById = (articleId) => { - return prisma.article.findUnique({ - where: { id: articleId }, - omit: { updatedAt: true }, - }); +const findById = (userId, articleId) => { + return Promise.all([ + prisma.article.findUnique({ + where: { id: articleId }, + omit: { updatedAt: true }, + }), + prisma.articleLike.findUnique({ + where: { userId_articleId: { userId, articleId } }, + }), + ]); }; const findByIdWithTx = (tx, articleId) => { @@ -55,6 +60,16 @@ const deleteWithTx = (tx, articleId) => { }); }; +const addlikeArticle = (userId, articleId) => { + return prisma.articleLike.create({ data: { userId, articleId } }); +}; + +const cancelLikeArticle = (userId, articleId) => { + return prisma.articleLike.delete({ + where: { userId_articleId: { userId, articleId } }, + }); +}; + export default { findAll, findById, @@ -62,4 +77,6 @@ export default { create, updateWithTx, deleteWithTx, + addlikeArticle, + cancelLikeArticle, }; diff --git a/src/repositories/productRepository.js b/src/repositories/productRepository.js index 79c54748..8e20f9be 100644 --- a/src/repositories/productRepository.js +++ b/src/repositories/productRepository.js @@ -21,11 +21,19 @@ const findAll = (query) => { ]); }; -const findByIdWithTx = (tx, productId) => { - return tx.product.findUnique({ - where: { id: productId }, - omit: { updatedAt: true }, - }); +const findByIdWithTx = (tx, userId, productId) => { + return Promise.all([ + tx.product.findUnique({ + where: { id: productId }, + omit: { updatedAt: true }, + }), + tx.productImage.findMany({ + where: { productId }, + }), + tx.productLike.findUnique({ + where: { userId_productId: { userId, productId } }, + }), + ]); }; const findProductTagByIdWithTx = (tx, productId) => { @@ -39,7 +47,13 @@ const createWithTx = (tx, body) => { const { name, description, price } = body; return tx.product.create({ - data: { name, description, price }, + data: { name, description, price: Number(price) }, + }); +}; + +const createProductImageWithTx = (tx, imageUrl = "", userId, productId) => { + return tx.productImage.create({ + data: { imageUrl, userId, productId }, }); }; @@ -72,15 +86,28 @@ const deleteProductWithTx = (tx, productId) => { return tx.product.delete({ where: { id: productId } }); }; +const addlikeProduct = (userId, productId) => { + return prisma.productLike.create({ data: { userId, productId } }); +}; + +const cancelLikeProduct = (userId, productId) => { + return prisma.productLike.delete({ + where: { userId_productId: { userId, productId } }, + }); +}; + export default { findAll, findByIdWithTx, findProductTagByIdWithTx, createWithTx, + createProductImageWithTx, findTagByNameWithTx, createTagWithTx, createProductTagWithTx, updateProductWithTx, deleteProductTagsWithTx, deleteProductWithTx, + addlikeProduct, + cancelLikeProduct, }; diff --git a/src/routes/articleRoutes.js b/src/routes/articleRoutes.js index 722cd83d..fe951b51 100644 --- a/src/routes/articleRoutes.js +++ b/src/routes/articleRoutes.js @@ -18,4 +18,10 @@ articleRouter.patch("/:articleId", articleController.updateArticle); // 게시글 삭제 articleRouter.delete("/:articleId", articleController.deleteArticle); +// 게시글 좋아요 +articleRouter.post("/:articleId/like", articleController.addlikeArticle); + +// 게시글 좋아요 취소 +articleRouter.delete("/:articleId/like", articleController.cancelLikeArticle); + export default articleRouter; diff --git a/src/routes/productRoutes.js b/src/routes/productRoutes.js index 80a5017e..ac08dc1e 100644 --- a/src/routes/productRoutes.js +++ b/src/routes/productRoutes.js @@ -1,9 +1,13 @@ import express from "express"; import requiredDataValidate from "../middlewares/requiredDataValidate.js"; import productController from "../controllers/productController.js"; +import multer from "multer"; const productRouter = express.Router(); +// 이미지 업로드 +const upload = multer({ dest: "uploads/" }); + // 상품 목록 불러오기 productRouter.get("/", productController.getProducts); @@ -11,7 +15,12 @@ productRouter.get("/", productController.getProducts); productRouter.get("/:productId", productController.getProduct); // 상품 등록 -productRouter.post("/", requiredDataValidate, productController.createProduct); +productRouter.post( + "/", + upload.array("imageFiles", 3), + requiredDataValidate, + productController.createProduct +); // 상품 수정 productRouter.patch("/:productId", productController.updateProduct); @@ -20,7 +29,9 @@ productRouter.patch("/:productId", productController.updateProduct); productRouter.delete("/:productId", productController.deleteProduct); // 상품 좋아요 +productRouter.post("/:productId/like", productController.addlikeProduct); // 상품 좋아요 취소 +productRouter.delete("/:productId/like", productController.cancelLikeProduct); export default productRouter; diff --git a/src/services/articleService.js b/src/services/articleService.js index 49bc2b8e..78eedd47 100644 --- a/src/services/articleService.js +++ b/src/services/articleService.js @@ -16,8 +16,11 @@ const getArticles = async (query) => { }; // 게시글 상세조회 -const getArticle = async (articleId) => { - const article = await articleRepository.findById(articleId); +const getArticle = async (userId, articleId) => { + const [article, isLiked] = await articleRepository.findById( + userId, + articleId + ); if (!article) { const error = new Error("해당 게시글을 찾을 수 없습니다."); @@ -26,7 +29,7 @@ const getArticle = async (articleId) => { throw error; } - return article; + return { ...article, isLiked: !!isLiked }; }; // 게시글 작성 @@ -84,10 +87,21 @@ const deleteArticle = async (articleId) => { }); }; +// 게시글 좋아요 +const addlikeArticle = (userId, articleId) => { + return articleRepository.addlikeArticle(userId, articleId); +}; + +// 게시글 좋아요 취소 +const cancelLikeArticle = (userId, articleId) => { + return articleRepository.cancelLikeArticle(userId, articleId); +}; export default { getArticles, getArticle, createArticle, updateArticle, deleteArticle, + addlikeArticle, + cancelLikeArticle, }; diff --git a/src/services/productService.js b/src/services/productService.js index bf383164..d0dbe3f2 100644 --- a/src/services/productService.js +++ b/src/services/productService.js @@ -16,9 +16,13 @@ const getProducts = async (query) => { }; // 상품 상세조회 -const getProduct = async (productId) => { +const getProduct = async (userId, productId) => { return await prisma.$transaction(async (tx) => { - const product = await productRepository.findByIdWithTx(tx, productId); + const [product, images, isLiked] = await productRepository.findByIdWithTx( + tx, + userId, + productId + ); if (!product) { const error = new Error("존재하지 않는 상품입니다."); @@ -33,27 +37,21 @@ const getProduct = async (productId) => { ); const tags = productTag.map((tag) => tag.tag.name); + const imageUrls = images.map((image) => image.imageUrl); - return { ...product, tags }; + return { ...product, tags, images: imageUrls, isLiked: !!isLiked }; }); }; // 상품 등록 -const createProduct = async (body) => { - const { name, description, price, tags } = body; - - if (!name || !description || !price || !tags) { - const error = new Error("필수 항목을 모두 입력해주세요."); - error.code = 400; - - throw error; - } +const createProduct = async (body, images) => { + const { tags } = body; return await prisma.$transaction(async (tx) => { const newProduct = await productRepository.createWithTx(tx, body); const newTags = await Promise.all( - tags.map(async (tagName) => { + JSON.parse(tags).map(async (tagName) => { let tag = await productRepository.findTagByNameWithTx(tx, tagName); if (!tag) { @@ -70,7 +68,20 @@ const createProduct = async (body) => { }) ); - return { ...newProduct, tags: newTags }; + const newImages = await Promise.all( + images.map(async (image) => { + const newImage = await productRepository.createProductImageWithTx( + tx, + image.filename, + userId, + newProduct.id + ); + + return newImage.imageUrl; + }) + ); + + return { ...newProduct, tags: newTags, images: newImages }; }); }; @@ -128,10 +139,22 @@ const deleteProduct = async (productId) => { }); }; +// 상품 좋아요 +const addlikeProduct = (userId, productId) => { + return productRepository.addlikeProduct(userId, productId); +}; + +// 상품 좋아요 취소 +const cancelLikeProduct = (userId, productId) => { + return productRepository.cancelLikeProduct(userId, productId); +}; + export default { getProducts, getProduct, createProduct, updateProduct, deleteProduct, + addlikeProduct, + cancelLikeProduct, }; From 70c8984b10a81a1620372a9499bfb02dfeb3c36f Mon Sep 17 00:00:00 2001 From: Decal Date: Sat, 17 May 2025 03:46:30 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20refrashToken=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=A1=9C=EC=A7=81,=20JWT=20=EB=B3=B5=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/middlewares/auth.js | 16 ++++++++++++++++ src/middlewares/errorHandler.js | 4 ++++ src/routes/userRoutes.js | 26 ++++++++++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 src/middlewares/auth.js diff --git a/.gitignore b/.gitignore index 4baec517..52db2372 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist-ssr *.http HTTP temp +uploads jsconfig.json # Editor directories and files diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js new file mode 100644 index 00000000..36872f5a --- /dev/null +++ b/src/middlewares/auth.js @@ -0,0 +1,16 @@ +import { expressjwt } from "express-jwt"; + +const verifyAccessToken = expressjwt({ + secret: process.env.JWT_SECRET, + algorithms: ["HS256"], +}); + +const verifyRefreshToken = expressjwt({ + secret: process.env.JWT_SECRET, + algorithms: ["HS256"], +}); + +export default { + verifyAccessToken, + verifyRefreshToken, +}; diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 514464dd..805c6528 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -1,6 +1,10 @@ export default function errorHandler(err, req, res, next) { const status = err.code ?? 500; + if (err.name === "UnauthorizedError") { + return res.status(401).json({ message: "토큰이 유효하지 않습니다." }); + } + return res.status(status).json({ path: req.path, method: req.method, diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index deea650e..3ae9e6de 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -2,6 +2,7 @@ import express from "express"; import prisma from "../../prisma/client.prisma.js"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; +import auth from "../middlewares/auth.js"; const userRouter = express.Router(); @@ -110,4 +111,29 @@ userRouter.post("/login", async (req, res, next) => { } }); +userRouter.post( + "/refresh-token", + auth.verifyRefreshToken, + async (req, res, next) => { + const userId = req.auth.id; + try { + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (!user) { + const error = new Error("인증에 실패하였습니다."); + error.code = 401; + + throw error; + } + + const accessToken = createToken(user); + const refreshToken = createToken(user, "refreshToken"); + + res.status(200).json({ accessToken, refreshToken }); + } catch (e) { + next(e); + } + } +); + export default userRouter; From d51d0d6865a378fc9013a6885bea41b2b823cfdf Mon Sep 17 00:00:00 2001 From: Decal Date: Sat, 17 May 2025 03:46:56 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D,=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/productController.js | 34 +++++++++++++++++++++---- src/repositories/productRepository.js | 10 +++++++- src/routes/productRoutes.js | 36 ++++++++++++++++++++++----- src/services/productService.js | 32 +++++++++++++++--------- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/controllers/productController.js b/src/controllers/productController.js index 7eb51fe5..c51254d9 100644 --- a/src/controllers/productController.js +++ b/src/controllers/productController.js @@ -2,10 +2,18 @@ import productService from "../services/productService.js"; // 상품 목록 불러오기 const getProducts = async (req, res, next) => { + const baseUrl = `${req.protocol}://${req.get("host")}/images`; + try { const [products, totalCount] = await productService.getProducts(req.query); - res.status(200).json({ list: products, totalCount }); + const productsWithImages = products.map((product) => ({ + ...product, + productImages: undefined, + images: product.productImages.map((img) => `${baseUrl}/${img.imageUrl}`), + })); + + res.status(200).json({ list: productsWithImages, totalCount }); } catch (e) { next(e); } @@ -13,6 +21,7 @@ const getProducts = async (req, res, next) => { // 상품 상세조회 const getProduct = async (req, res, next) => { + const userId = req.auth.id; const productId = Number(req.params.productId); const baseUrl = `${req.protocol}://${req.get("host")}/images`; @@ -31,11 +40,16 @@ const getProduct = async (req, res, next) => { // 상품 등록 const createProduct = async (req, res, next) => { + const userId = req.auth.id; const images = req.files; const baseUrl = `${req.protocol}://${req.get("host")}/images`; try { - const newProduct = await productService.createProduct(req.body, images); + const newProduct = await productService.createProduct( + userId, + req.body, + images + ); const imageUrls = newProduct.images.map( (imageUrl) => `${baseUrl}/${imageUrl}` @@ -49,15 +63,24 @@ const createProduct = async (req, res, next) => { // 상품 수정 const updateProduct = async (req, res, next) => { + const userId = req.auth.id; const productId = Number(req.params.productId); + const images = req.files; + const baseUrl = `${req.protocol}://${req.get("host")}/images`; try { const updatedProduct = await productService.updateProduct( + userId, productId, - req.body + req.body, + images + ); + + const imageUrls = updatedProduct.images.map( + (imageUrl) => `${baseUrl}/${imageUrl}` ); - res.status(200).json(updatedProduct); + res.status(200).json({ ...updatedProduct, images: imageUrls }); } catch (e) { next(e); } @@ -76,9 +99,9 @@ const deleteProduct = async (req, res, next) => { } }; -// TODO: 상품 상세조회, 상품 좋아요, 상품 좋아요 취소에 user 인증 미들웨어 달고, req.auth로 들어오는 user정보에서 id를 추출한 다음에 userId를 넘겨주기 // 상품 좋아요 const addlikeProduct = async (req, res, next) => { + const userId = req.auth.id; const productId = Number(req.params.productId); try { @@ -92,6 +115,7 @@ const addlikeProduct = async (req, res, next) => { // 상품 좋아요 취소 const cancelLikeProduct = async (req, res, next) => { + const userId = req.auth.id; const productId = Number(req.params.productId); try { diff --git a/src/repositories/productRepository.js b/src/repositories/productRepository.js index 8e20f9be..a3ad8b89 100644 --- a/src/repositories/productRepository.js +++ b/src/repositories/productRepository.js @@ -16,6 +16,7 @@ const findAll = (query) => { take: Number(limit) || 10, orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, omit: { description: true, updatedAt: true }, + include: { productImages: { select: { imageUrl: true } } }, }), prisma.product.count({ where: filter }), ]); @@ -57,6 +58,12 @@ const createProductImageWithTx = (tx, imageUrl = "", userId, productId) => { }); }; +const deleteProductImageWithTx = (tx, productId) => { + return tx.productImage.deleteMany({ + where: { productId }, + }); +}; + const findTagByNameWithTx = (tx, tagName) => { return tx.tag.findUnique({ where: { name: tagName } }); }; @@ -74,7 +81,7 @@ const updateProductWithTx = (tx, productId, body) => { return tx.product.update({ where: { id: productId }, - data: { name, description, price }, + data: { name, description, price: Number(price) }, }); }; @@ -106,6 +113,7 @@ export default { createTagWithTx, createProductTagWithTx, updateProductWithTx, + deleteProductImageWithTx, deleteProductTagsWithTx, deleteProductWithTx, addlikeProduct, diff --git a/src/routes/productRoutes.js b/src/routes/productRoutes.js index ac08dc1e..5690bb69 100644 --- a/src/routes/productRoutes.js +++ b/src/routes/productRoutes.js @@ -2,6 +2,7 @@ import express from "express"; import requiredDataValidate from "../middlewares/requiredDataValidate.js"; import productController from "../controllers/productController.js"; import multer from "multer"; +import auth from "../middlewares/auth.js"; const productRouter = express.Router(); @@ -9,29 +10,52 @@ const productRouter = express.Router(); const upload = multer({ dest: "uploads/" }); // 상품 목록 불러오기 -productRouter.get("/", productController.getProducts); +productRouter.get("/", auth.verifyAccessToken, productController.getProducts); // 상품 상세조회 -productRouter.get("/:productId", productController.getProduct); +productRouter.get( + "/:productId", + auth.verifyAccessToken, + productController.getProduct +); // 상품 등록 productRouter.post( "/", + auth.verifyAccessToken, upload.array("imageFiles", 3), requiredDataValidate, productController.createProduct ); // 상품 수정 -productRouter.patch("/:productId", productController.updateProduct); +productRouter.patch( + "/:productId", + auth.verifyAccessToken, + upload.array("imageFiles", 3), + requiredDataValidate, + productController.updateProduct +); // 상품 삭제 -productRouter.delete("/:productId", productController.deleteProduct); +productRouter.delete( + "/:productId", + auth.verifyAccessToken, + productController.deleteProduct +); // 상품 좋아요 -productRouter.post("/:productId/like", productController.addlikeProduct); +productRouter.post( + "/:productId/like", + auth.verifyAccessToken, + productController.addlikeProduct +); // 상품 좋아요 취소 -productRouter.delete("/:productId/like", productController.cancelLikeProduct); +productRouter.delete( + "/:productId/like", + auth.verifyAccessToken, + productController.cancelLikeProduct +); export default productRouter; diff --git a/src/services/productService.js b/src/services/productService.js index d0dbe3f2..f4dd19a3 100644 --- a/src/services/productService.js +++ b/src/services/productService.js @@ -44,7 +44,7 @@ const getProduct = async (userId, productId) => { }; // 상품 등록 -const createProduct = async (body, images) => { +const createProduct = async (userId, body, images) => { const { tags } = body; return await prisma.$transaction(async (tx) => { @@ -86,15 +86,8 @@ const createProduct = async (body, images) => { }; // 상품 수정 -const updateProduct = async (productId, body) => { - const { name, description, price, tags } = body; - - if (!(name || description || price || tags)) { - const error = new Error("수정할 내용을 입력해주세요."); - error.code = 400; - - throw error; - } +const updateProduct = async (userId, productId, body, images) => { + const { tags } = body; return await prisma.$transaction(async (tx) => { const updatedProduct = await productRepository.updateProductWithTx( @@ -106,7 +99,7 @@ const updateProduct = async (productId, body) => { await productRepository.deleteProductTagsWithTx(tx, productId); const updatedTags = await Promise.all( - tags.map(async (tagName) => { + JSON.parse(tags).map(async (tagName) => { let tag = await productRepository.findTagByNameWithTx(tx, tagName); if (!tag) { @@ -119,7 +112,22 @@ const updateProduct = async (productId, body) => { }) ); - return { ...updatedProduct, tags: updatedTags }; + await productRepository.deleteProductImageWithTx(tx, productId); + + const updatedImages = await Promise.all( + images.map(async (image) => { + const updatedImage = await productRepository.createProductImageWithTx( + tx, + image.filename, + userId, + productId + ); + + return updatedImage.imageUrl; + }) + ); + + return { ...updatedProduct, tags: updatedTags, images: updatedImages }; }); }; From a3978d649b869588cc600ebdf7d6e00ca7d7833e Mon Sep 17 00:00:00 2001 From: Decal Date: Sun, 18 May 2025 23:06:07 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/userRoutes.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 3ae9e6de..f6970f20 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -111,6 +111,23 @@ userRouter.post("/login", async (req, res, next) => { } }); +// 유저 정보 불러오기 +userRouter.get("/me", auth.verifyAccessToken, async (req, res, next) => { + const userId = req.auth.id; + + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, nickname: true, image: true }, + }); + + res.status(200).json(user); + } catch (e) { + next(e); + } +}); + +// 액세스 토큰 재발급 userRouter.post( "/refresh-token", auth.verifyRefreshToken, From 588c09abdf135c3610fcb78f89061cb7c9a00884 Mon Sep 17 00:00:00 2001 From: Decal Date: Mon, 19 May 2025 15:01:32 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D/=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 30 +++ .../migration.sql | 12 + .../migration.sql | 23 ++ prisma/schema.prisma | 52 ++-- src/controllers/articleController.js | 9 +- src/repositories/articleRepository.js | 26 +- src/repositories/productRepository.js | 28 +- src/routes/articleRoutes.js | 39 ++- src/routes/commentRoutes.js | 245 ++++++++++-------- src/routes/productRoutes.js | 18 +- src/services/articleService.js | 20 +- src/services/productService.js | 38 ++- 12 files changed, 359 insertions(+), 181 deletions(-) create mode 100644 prisma/migrations/20250518150827_add_author_field/migration.sql create mode 100644 prisma/migrations/20250518152424_remove_author_optional/migration.sql create mode 100644 prisma/migrations/20250519011947_add_on_delete_cascade/migration.sql diff --git a/prisma/migrations/20250518150827_add_author_field/migration.sql b/prisma/migrations/20250518150827_add_author_field/migration.sql new file mode 100644 index 00000000..e52d584d --- /dev/null +++ b/prisma/migrations/20250518150827_add_author_field/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - Added the required column `authorId` to the `Article` table without a default value. This is not possible if the table is not empty. + - Added the required column `authorId` to the `ArticleComment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Article" ADD COLUMN "authorId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ArticleComment" ADD COLUMN "authorId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "authorId" TEXT; + +-- AlterTable +ALTER TABLE "ProductComment" ADD COLUMN "authorId" TEXT; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductComment" ADD CONSTRAINT "ProductComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleComment" ADD CONSTRAINT "ArticleComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250518152424_remove_author_optional/migration.sql b/prisma/migrations/20250518152424_remove_author_optional/migration.sql new file mode 100644 index 00000000..b0144c98 --- /dev/null +++ b/prisma/migrations/20250518152424_remove_author_optional/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `authorId` on table `Product` required. This step will fail if there are existing NULL values in that column. + - Made the column `authorId` on table `ProductComment` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Product" ALTER COLUMN "authorId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "ProductComment" ALTER COLUMN "authorId" SET NOT NULL; diff --git a/prisma/migrations/20250519011947_add_on_delete_cascade/migration.sql b/prisma/migrations/20250519011947_add_on_delete_cascade/migration.sql new file mode 100644 index 00000000..6c46cf33 --- /dev/null +++ b/prisma/migrations/20250519011947_add_on_delete_cascade/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "ArticleLike" DROP CONSTRAINT "ArticleLike_articleId_fkey"; + +-- DropForeignKey +ALTER TABLE "ArticleLike" DROP CONSTRAINT "ArticleLike_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ProductImage" DROP CONSTRAINT "ProductImage_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "ProductImage" DROP CONSTRAINT "ProductImage_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleLike" ADD CONSTRAINT "ArticleLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleLike" ADD CONSTRAINT "ArticleLike_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f213b82..e80ebe52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,20 +9,20 @@ datasource db { // 사용자 model User { - id String @id @default(uuid()) - email String @unique - password String - nickname String @unique - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - // products Product[] - // productComments ProductComment[] - productLikes ProductLike[] - // articles Article[] - // articleComments ArticleComment[] - articleLikes ArticleLike[] - productImagea ProductImage[] + id String @id @default(uuid()) + email String @unique + password String + nickname String @unique + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + products Product[] + productComments ProductComment[] + productLikes ProductLike[] + articles Article[] + articleComments ArticleComment[] + articleLikes ArticleLike[] + productImagea ProductImage[] } // 상품 @@ -33,8 +33,8 @@ model Product { price Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // user User @relation(fields: [userId], references: [id], onDelete: Cascade) - // userId String + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) productLikes ProductLike[] productComments ProductComment[] productTags ProductTag[] @@ -64,8 +64,8 @@ model ProductComment { content String productId Int createdAt DateTime @default(now()) - // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - // authorId String + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) } @@ -88,8 +88,8 @@ model ProductImage { userId String productId Int createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - product Product @relation(fields: [productId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) } // 자유 게시판 @@ -99,8 +99,8 @@ model Article { content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - // authorId String + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) articleComments ArticleComment[] articleLikes ArticleLike[] } @@ -111,8 +111,8 @@ model ArticleComment { content String createdAt DateTime @default(now()) articleId Int - // author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - // authorId String + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) } @@ -122,8 +122,8 @@ model ArticleLike { createdAt DateTime @default(now()) userId String articleId Int - user User @relation(fields: [userId], references: [id]) - article Article @relation(fields: [articleId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) @@unique([userId, articleId]) } diff --git a/src/controllers/articleController.js b/src/controllers/articleController.js index d9d4ef64..c4eda955 100644 --- a/src/controllers/articleController.js +++ b/src/controllers/articleController.js @@ -13,6 +13,7 @@ const getArticles = async (req, res, next) => { // 게시글 상세조회 const getArticle = async (req, res, next) => { + const userId = req.auth.id; const articleId = Number(req.params.articleId); try { @@ -26,8 +27,10 @@ const getArticle = async (req, res, next) => { // 게시글 작성 const createArticle = async (req, res, next) => { + const userId = req.auth.id; + try { - const newArticle = await articleService.createArticle(req.body); + const newArticle = await articleService.createArticle(userId, req.body); res.status(201).json(newArticle); } catch (e) { @@ -64,10 +67,9 @@ const deleteArticle = async (req, res, next) => { } }; -// TODO: 게시글 상세조회, 게시글 좋아요, 게시글 좋아요 취소에 user 인증 미들웨어 달고, req.auth로 들어오는 user정보에서 id를 추출한 다음에 userId를 넘겨주기 -// TODO: 게시글 좋아요 전체 갯수 알 수 있는 목록도 반환시키기 // 게시글 좋아요 const addlikeArticle = async (req, res, next) => { + const userId = req.auth.id; const articleId = Number(req.params.articleId); try { @@ -81,6 +83,7 @@ const addlikeArticle = async (req, res, next) => { // 게시글 좋아요 취소 const cancelLikeArticle = async (req, res, next) => { + const userId = req.auth.id; const articleId = Number(req.params.articleId); try { diff --git a/src/repositories/articleRepository.js b/src/repositories/articleRepository.js index 695f60e8..c8f0e48f 100644 --- a/src/repositories/articleRepository.js +++ b/src/repositories/articleRepository.js @@ -8,24 +8,37 @@ const findAll = (query) => { { content: { contains: keyword || "", mode: "insensitive" } }, ], }; + const orderByCondition = + orderBy === "recent" + ? { createdAt: "desc" } + : { articleLikes: { _count: "desc" } }; return Promise.all([ prisma.article.findMany({ where: filter, skip: (Number(offset) - 1) * Number(limit) || 0, take: Number(limit) || 10, - orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, - omit: { updatedAt: true }, + orderBy: orderByCondition, + omit: { updatedAt: true, authorId: true }, + include: { author: { select: { nickname: true } } }, }), prisma.article.count({ where: filter }), ]); }; +const findArticleLikeCountById = (articleId) => { + return prisma.articleLike.count({ where: { articleId } }); +}; + const findById = (userId, articleId) => { return Promise.all([ prisma.article.findUnique({ where: { id: articleId }, - omit: { updatedAt: true }, + omit: { updatedAt: true, authorId: true }, + include: { author: { select: { id: true, nickname: true } } }, + }), + prisma.articleLike.count({ + where: { articleId }, }), prisma.articleLike.findUnique({ where: { userId_articleId: { userId, articleId } }, @@ -39,9 +52,11 @@ const findByIdWithTx = (tx, articleId) => { }); }; -const create = (body) => { +const create = (userId, body) => { + const { title, content } = body; + return prisma.article.create({ - data: body, + data: { title, content, authorId: userId }, }); }; @@ -72,6 +87,7 @@ const cancelLikeArticle = (userId, articleId) => { export default { findAll, + findArticleLikeCountById, findById, findByIdWithTx, create, diff --git a/src/repositories/productRepository.js b/src/repositories/productRepository.js index a3ad8b89..6bec434d 100644 --- a/src/repositories/productRepository.js +++ b/src/repositories/productRepository.js @@ -8,35 +8,51 @@ const findAll = (query) => { { description: { contains: keyword || "", mode: "insensitive" } }, ], }; + const orderByCondition = + orderBy === "recent" + ? { createdAt: "desc" } + : { productLikes: { _count: "desc" } }; return Promise.all([ prisma.product.findMany({ where: filter, skip: (Number(offset) - 1) * Number(limit) || 0, take: Number(limit) || 10, - orderBy: { createdAt: orderBy === "recent" ? "desc" : "asc" }, - omit: { description: true, updatedAt: true }, + orderBy: orderByCondition, + omit: { description: true, authorId: true, updatedAt: true }, include: { productImages: { select: { imageUrl: true } } }, }), prisma.product.count({ where: filter }), ]); }; +const findProductLikeCountById = (productId) => { + return prisma.productLike.count({ where: { productId } }); +}; + const findByIdWithTx = (tx, userId, productId) => { return Promise.all([ tx.product.findUnique({ where: { id: productId }, - omit: { updatedAt: true }, + omit: { updatedAt: true, authorId: true }, + include: { author: { select: { id: true, nickname: true } } }, }), tx.productImage.findMany({ where: { productId }, }), + tx.productLike.count({ + where: { productId }, + }), tx.productLike.findUnique({ where: { userId_productId: { userId, productId } }, }), ]); }; +const findOnlyProductByIdWithTx = (tx, productId) => { + return tx.product.findUnique({ where: { id: productId } }); +}; + const findProductTagByIdWithTx = (tx, productId) => { return tx.productTag.findMany({ where: { productId }, @@ -44,11 +60,11 @@ const findProductTagByIdWithTx = (tx, productId) => { }); }; -const createWithTx = (tx, body) => { +const createWithTx = (tx, userId, body) => { const { name, description, price } = body; return tx.product.create({ - data: { name, description, price: Number(price) }, + data: { name, description, price: Number(price), authorId: userId }, }); }; @@ -105,7 +121,9 @@ const cancelLikeProduct = (userId, productId) => { export default { findAll, + findProductLikeCountById, findByIdWithTx, + findOnlyProductByIdWithTx, findProductTagByIdWithTx, createWithTx, createProductImageWithTx, diff --git a/src/routes/articleRoutes.js b/src/routes/articleRoutes.js index fe951b51..44403594 100644 --- a/src/routes/articleRoutes.js +++ b/src/routes/articleRoutes.js @@ -1,27 +1,52 @@ import express from "express"; import articleController from "../controllers/articleController.js"; +import auth from "../middlewares/auth.js"; const articleRouter = express.Router(); // 게시글 목록 불러오기 -articleRouter.get("/", articleController.getArticles); +articleRouter.get("/", auth.verifyAccessToken, articleController.getArticles); // 게시글 상세조회 -articleRouter.get("/:articleId", articleController.getArticle); +articleRouter.get( + "/:articleId", + auth.verifyAccessToken, + articleController.getArticle +); // 게시글 작성 -articleRouter.post("/", articleController.createArticle); +articleRouter.post( + "/", + auth.verifyAccessToken, + articleController.createArticle +); // 게시글 수정 -articleRouter.patch("/:articleId", articleController.updateArticle); +articleRouter.patch( + "/:articleId", + auth.verifyAccessToken, + articleController.updateArticle +); // 게시글 삭제 -articleRouter.delete("/:articleId", articleController.deleteArticle); +articleRouter.delete( + "/:articleId", + auth.verifyAccessToken, + articleController.deleteArticle +); // 게시글 좋아요 -articleRouter.post("/:articleId/like", articleController.addlikeArticle); +articleRouter.post( + "/:articleId/like", + auth.verifyAccessToken, + articleController.addlikeArticle +); // 게시글 좋아요 취소 -articleRouter.delete("/:articleId/like", articleController.cancelLikeArticle); +articleRouter.delete( + "/:articleId/like", + auth.verifyAccessToken, + articleController.cancelLikeArticle +); export default articleRouter; diff --git a/src/routes/commentRoutes.js b/src/routes/commentRoutes.js index 547731a6..b3e0461a 100644 --- a/src/routes/commentRoutes.js +++ b/src/routes/commentRoutes.js @@ -1,5 +1,6 @@ import express from "express"; import prisma from "../../prisma/client.prisma.js"; +import auth from "../middlewares/auth.js"; const commentRouter = express.Router(); @@ -7,78 +8,94 @@ const ARTICLE_COMMENT = "/articles/:articleId/comments"; const PRODUCT_COMMENT = "/products/:productId/comments"; // 게시글 댓글 불러오기 -commentRouter.get(`${ARTICLE_COMMENT}`, async (req, res, next) => { - try { - const articleId = Number(req.params.articleId); - const { limit, cursor } = req.query; +commentRouter.get( + `${ARTICLE_COMMENT}`, + auth.verifyAccessToken, + async (req, res, next) => { + try { + const articleId = Number(req.params.articleId); + const { limit, cursor } = req.query; - await prisma.$transaction(async (tx) => { - const articleCommentId = cursor - ? await tx.articleComment.findFirst({ + await prisma.$transaction(async (tx) => { + const articleCommentId = + cursor && + (await tx.articleComment.findFirst({ where: { articleId, id: Number(cursor) }, - }) - : false; - - const articleComment = await tx.articleComment.findMany({ - where: { articleId }, - skip: articleCommentId ? 1 : undefined, - take: Number(limit) || 10, - cursor: articleCommentId ? { id: Number(cursor) } : undefined, - omit: { articleId: true }, - }); + })); + + const articleComment = await tx.articleComment.findMany({ + where: { articleId }, + skip: articleCommentId ? 1 : undefined, + take: Number(limit) || 10, + cursor: articleCommentId ? { id: Number(cursor) } : undefined, + omit: { articleId: true, authorId: true }, + include: { author: { select: { id: true, nickname: true } } }, + }); - res.json(articleComment); - }); - } catch (e) { - next(e); + res.json(articleComment); + }); + } catch (e) { + next(e); + } } -}); +); // 게시글 댓글 작성 -commentRouter.post(`${ARTICLE_COMMENT}`, async (req, res, next) => { - try { - const articleId = Number(req.params.articleId); +commentRouter.post( + `${ARTICLE_COMMENT}`, + auth.verifyAccessToken, + async (req, res, next) => { + const userId = req.auth.id; + + try { + const articleId = Number(req.params.articleId); - const { content } = req.body; + const { content } = req.body; - const newArticleComment = await prisma.articleComment.create({ - data: { content, articleId }, - }); + const newArticleComment = await prisma.articleComment.create({ + data: { authorId: userId, articleId, content }, + }); - res.status(201).json(newArticleComment); - } catch (e) { - next(e); + res.status(201).json(newArticleComment); + } catch (e) { + next(e); + } } -}); +); // 게시글 댓글 수정 -commentRouter.patch(`${ARTICLE_COMMENT}/:commentId`, async (req, res, next) => { - try { - const articleId = Number(req.params.articleId); - const commentId = Number(req.params.commentId); - const { content } = req.body; +commentRouter.patch( + `${ARTICLE_COMMENT}/:commentId`, + auth.verifyAccessToken, + async (req, res, next) => { + try { + const articleId = Number(req.params.articleId); + const commentId = Number(req.params.commentId); + const { content } = req.body; - await prisma.$transaction(async (tx) => { - const articleComment = await tx.articleComment.findUnique({ - where: { articleId, id: commentId }, - }); - if (!articleComment) throw new Error("존재하지 않는 댓글입니다."); + await prisma.$transaction(async (tx) => { + const articleComment = await tx.articleComment.findUnique({ + where: { articleId, id: commentId }, + }); + if (!articleComment) throw new Error("존재하지 않는 댓글입니다."); - const updateArticleComment = await tx.articleComment.update({ - where: { articleId, id: commentId }, - data: { content }, - }); + const updateArticleComment = await tx.articleComment.update({ + where: { articleId, id: commentId }, + data: { content }, + }); - res.status(200).json(updateArticleComment); - }); - } catch (e) { - next(e); + res.status(200).json(updateArticleComment); + }); + } catch (e) { + next(e); + } } -}); +); // 게시글 댓글 삭제 commentRouter.delete( `${ARTICLE_COMMENT}/:commentId`, + auth.verifyAccessToken, async (req, res, next) => { const articleId = Number(req.params.articleId); const commentId = Number(req.params.commentId); @@ -104,78 +121,94 @@ commentRouter.delete( ); // 상품 댓글 불러오기 -commentRouter.get(`${PRODUCT_COMMENT}`, async (req, res, next) => { - try { - const productId = Number(req.params.productId); - const { limit, cursor } = req.query; - - await prisma.$transaction(async (tx) => { - const productCommentId = cursor - ? await tx.productComment.findFirst({ - where: { productId, id: Number(cursor) }, - }) - : false; - - const productComment = await tx.productComment.findMany({ - where: { productId }, - skip: productCommentId ? 1 : undefined, - take: Number(limit) || 10, - cursor: productCommentId ? { id: Number(cursor) } : undefined, - omit: { productId: true }, - }); +commentRouter.get( + `${PRODUCT_COMMENT}`, + auth.verifyAccessToken, + async (req, res, next) => { + try { + const productId = Number(req.params.productId); + const { limit, cursor } = req.query; + + await prisma.$transaction(async (tx) => { + const productCommentId = cursor + ? await tx.productComment.findFirst({ + where: { productId, id: Number(cursor) }, + }) + : false; + + const productComment = await tx.productComment.findMany({ + where: { productId }, + skip: productCommentId ? 1 : undefined, + take: Number(limit) || 10, + cursor: productCommentId ? { id: Number(cursor) } : undefined, + omit: { productId: true, authorId: true }, + include: { author: { select: { id: true, nickname: true } } }, + }); - res.json(productComment); - }); - } catch (e) { - next(e); + res.json(productComment); + }); + } catch (e) { + next(e); + } } -}); +); // 상품 댓글 작성 -commentRouter.post(`${PRODUCT_COMMENT}`, async (req, res, next) => { - try { - const productId = Number(req.params.productId); +commentRouter.post( + `${PRODUCT_COMMENT}`, + auth.verifyAccessToken, + async (req, res, next) => { + const userId = req.auth.id; - const { content } = req.body; + try { + const productId = Number(req.params.productId); - const newProductComment = await prisma.productComment.create({ - data: { content, productId }, - }); + const { content } = req.body; - res.status(201).json(newProductComment); - } catch (e) { - next(e); + const newProductComment = await prisma.productComment.create({ + data: { authorId: userId, productId, content }, + }); + + res.status(201).json(newProductComment); + } catch (e) { + next(e); + } } -}); +); // 상품 댓글 수정 -commentRouter.patch(`${PRODUCT_COMMENT}/:commentId`, async (req, res, next) => { - try { - const productId = Number(req.params.productId); - const commentId = Number(req.params.commentId); - const { content } = req.body; +commentRouter.patch( + `${PRODUCT_COMMENT}/:commentId`, + auth.verifyAccessToken, + async (req, res, next) => { + try { + const productId = Number(req.params.productId); + const commentId = Number(req.params.commentId); + const { content } = req.body; - await prisma.$transaction(async (tx) => { - const productComment = await tx.productComment.findUnique({ - where: { productId, id: commentId }, - }); - if (!productComment) throw new Error("존재하지 않는 댓글입니다."); + await prisma.$transaction(async (tx) => { + const productComment = await tx.productComment.findUnique({ + where: { productId, id: commentId }, + }); + if (!productComment) throw new Error("존재하지 않는 댓글입니다."); - const updateProductComment = await tx.productComment.update({ - where: { productId, id: commentId }, - data: { content }, - }); + const updateProductComment = await tx.productComment.update({ + where: { productId, id: commentId }, + data: { content }, + }); - res.status(200).json(updateProductComment); - }); - } catch (e) { - next(e); + res.status(200).json(updateProductComment); + }); + } catch (e) { + next(e); + } } -}); +); // 상품 댓글 삭제 commentRouter.delete( `${PRODUCT_COMMENT}/:commentId`, + auth.verifyAccessToken, async (req, res, next) => { const productId = Number(req.params.productId); const commentId = Number(req.params.commentId); diff --git a/src/routes/productRoutes.js b/src/routes/productRoutes.js index 5690bb69..6fb721a2 100644 --- a/src/routes/productRoutes.js +++ b/src/routes/productRoutes.js @@ -44,18 +44,10 @@ productRouter.delete( productController.deleteProduct ); -// 상품 좋아요 -productRouter.post( - "/:productId/like", - auth.verifyAccessToken, - productController.addlikeProduct -); - -// 상품 좋아요 취소 -productRouter.delete( - "/:productId/like", - auth.verifyAccessToken, - productController.cancelLikeProduct -); +// 상품 좋아요 & 좋아요 취소 +productRouter + .route("/:productId/like") + .post(auth.verifyAccessToken, productController.addlikeProduct) + .delete(auth.verifyAccessToken, productController.cancelLikeProduct); export default productRouter; diff --git a/src/services/articleService.js b/src/services/articleService.js index 78eedd47..23ffebce 100644 --- a/src/services/articleService.js +++ b/src/services/articleService.js @@ -12,12 +12,22 @@ const getArticles = async (query) => { throw error; } - return [articles, totalCount]; + const articletWithLikeCount = await Promise.all( + articles.map(async (article) => { + const likeCount = await articleRepository.findArticleLikeCountById( + article.id + ); + + return { ...article, likeCount }; + }) + ); + + return [articletWithLikeCount, totalCount]; }; // 게시글 상세조회 const getArticle = async (userId, articleId) => { - const [article, isLiked] = await articleRepository.findById( + const [article, likeCount, isLiked] = await articleRepository.findById( userId, articleId ); @@ -29,11 +39,11 @@ const getArticle = async (userId, articleId) => { throw error; } - return { ...article, isLiked: !!isLiked }; + return { ...article, likeCount, isLiked: !!isLiked }; }; // 게시글 작성 -const createArticle = (body) => { +const createArticle = (userId, body) => { const { title, content } = body; if (!title || !content) { @@ -43,7 +53,7 @@ const createArticle = (body) => { throw error; } - return articleRepository.create(body); + return articleRepository.create(userId, body); }; // 게시글 수정 diff --git a/src/services/productService.js b/src/services/productService.js index f4dd19a3..833b9053 100644 --- a/src/services/productService.js +++ b/src/services/productService.js @@ -12,17 +12,24 @@ const getProducts = async (query) => { throw error; } - return [products, totalCount]; + const productWithLikeCount = await Promise.all( + products.map(async (product) => { + const likeCount = await productRepository.findProductLikeCountById( + product.id + ); + + return { ...product, likeCount }; + }) + ); + + return [productWithLikeCount, totalCount]; }; // 상품 상세조회 const getProduct = async (userId, productId) => { return await prisma.$transaction(async (tx) => { - const [product, images, isLiked] = await productRepository.findByIdWithTx( - tx, - userId, - productId - ); + const [product, images, likeCount, isLiked] = + await productRepository.findByIdWithTx(tx, userId, productId); if (!product) { const error = new Error("존재하지 않는 상품입니다."); @@ -31,15 +38,21 @@ const getProduct = async (userId, productId) => { throw error; } - const productTag = await productRepository.findProductTagByIdWithTx( + const productTags = await productRepository.findProductTagByIdWithTx( tx, productId ); - const tags = productTag.map((tag) => tag.tag.name); + const tags = productTags.map((tag) => tag.tag.name); const imageUrls = images.map((image) => image.imageUrl); - return { ...product, tags, images: imageUrls, isLiked: !!isLiked }; + return { + ...product, + tags, + images: imageUrls, + likeCount, + isLiked: !!isLiked, + }; }); }; @@ -48,7 +61,7 @@ const createProduct = async (userId, body, images) => { const { tags } = body; return await prisma.$transaction(async (tx) => { - const newProduct = await productRepository.createWithTx(tx, body); + const newProduct = await productRepository.createWithTx(tx, userId, body); const newTags = await Promise.all( JSON.parse(tags).map(async (tagName) => { @@ -134,7 +147,10 @@ const updateProduct = async (userId, productId, body, images) => { // 상품 삭제 const deleteProduct = async (productId) => { return await prisma.$transaction(async (tx) => { - const product = await productRepository.findByIdWithTx(tx, productId); + const product = await productRepository.findOnlyProductByIdWithTx( + tx, + productId + ); if (!product) { const error = new Error("이미 삭제된 상품입니다.");