diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/sprint-mission-3 b/sprint-mission-3 new file mode 160000 index 00000000..fffc1765 --- /dev/null +++ b/sprint-mission-3 @@ -0,0 +1 @@ +Subproject commit fffc176556abcc21a77068d48bb997dcb98197d1 diff --git a/sprint-mission-4/.env.example b/sprint-mission-4/.env.example new file mode 100644 index 00000000..6dac7779 --- /dev/null +++ b/sprint-mission-4/.env.example @@ -0,0 +1,5 @@ +PORT=3000 +JWT_ACCESS_TOKEN_SECRET=your_jwt_access_token_secret +JWT_REFRESH_TOKEN_SECRET=your_jwt_refresh_token_secret +ACCESS_TOKEN_COOKIE_NAME=access-token +REFRESH_TOKEN_COOKIE_NAME=refresh-token diff --git a/sprint-mission-4/.gitignore b/sprint-mission-4/.gitignore new file mode 100644 index 00000000..233b2778 --- /dev/null +++ b/sprint-mission-4/.gitignore @@ -0,0 +1,16 @@ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +!*.code-workspace + +# Built Visual Studio Code Extensions +*.vsix +node_modules/ +package-lock.json +test.http +.githooks/ +.vscode/ +.env \ No newline at end of file diff --git a/sprint-mission-4/app.js b/sprint-mission-4/app.js new file mode 100644 index 00000000..eb7d80f7 --- /dev/null +++ b/sprint-mission-4/app.js @@ -0,0 +1,55 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import helmet from 'helmet'; +import authRouter from './routers/auth.router.js'; +import usersRouter from './routers/users.router.js'; +import productsRouter from './routers/products.router.js'; +import articlesRouter from './routers/articles.router.js'; +import postsRouter from './routers/posts.router.js'; // 새로 만든 posts.router.js 임포트 +import commentsRouter from './routers/comments.router.js'; +import { PORT } from './lib/constants.js'; + +const app = express(); + +// Security middleware +app.use(helmet()); + +// CORS middleware +app.use(cors()); + +// Middleware for logging requests +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`); + next(); +}); + +app.use(cookieParser()); +app.use(express.json()); + +// API routes +app.use('/api/auth', authRouter); +app.use('/api/users', usersRouter); +app.use('/api/products', productsRouter); +app.use('/api/articles', articlesRouter); +app.use('/api/posts', postsRouter); // /api/posts 경로에 새로운 postsRouter 할당 +app.use('/api/comments', commentsRouter); + +// 404 handler for unmatched routes +app.use((req, res) => { + res.status(404).json({ message: 'Route not found' }); +}); + +// Global error handler +app.use((err, req, res, next) => { + // Log the error for debugging purposes. Consider using a logger like Winston. + console.error(err); + + // Send a generic error message to the client + // Avoid sending stack trace in production + res.status(500).json({ message: 'Internal server error' }); +}); + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); diff --git a/sprint-mission-4/dev.db b/sprint-mission-4/dev.db new file mode 100644 index 00000000..7e2772fc Binary files /dev/null and b/sprint-mission-4/dev.db differ diff --git a/sprint-mission-4/lib/constants.js b/sprint-mission-4/lib/constants.js new file mode 100644 index 00000000..af8e48a9 --- /dev/null +++ b/sprint-mission-4/lib/constants.js @@ -0,0 +1,21 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +const NODE_ENV = process.env.NODE_ENV || 'development'; +const PORT = process.env.PORT || 3000; +const JWT_ACCESS_TOKEN_SECRET = + process.env.JWT_ACCESS_TOKEN_SECRET || 'your_jwt_access_token_secret'; +const JWT_REFRESH_TOKEN_SECRET = + process.env.JWT_REFRESH_TOKEN_SECRET || 'your_jwt_refresh_token_secret'; +const ACCESS_TOKEN_COOKIE_NAME = 'access-token'; +const REFRESH_TOKEN_COOKIE_NAME = 'refresh-token'; + +export { + NODE_ENV, + PORT, + JWT_ACCESS_TOKEN_SECRET, + JWT_REFRESH_TOKEN_SECRET, + ACCESS_TOKEN_COOKIE_NAME, + REFRESH_TOKEN_COOKIE_NAME, +}; diff --git a/sprint-mission-4/lib/prisma.js b/sprint-mission-4/lib/prisma.js new file mode 100644 index 00000000..4e54f7a7 --- /dev/null +++ b/sprint-mission-4/lib/prisma.js @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/sprint-mission-4/lib/token.js b/sprint-mission-4/lib/token.js new file mode 100644 index 00000000..66909d19 --- /dev/null +++ b/sprint-mission-4/lib/token.js @@ -0,0 +1,27 @@ +import jwt from 'jsonwebtoken'; +import { + JWT_ACCESS_TOKEN_SECRET, + JWT_REFRESH_TOKEN_SECRET, +} from './constants.js'; + +function generateTokens(userId) { + const accessToken = jwt.sign({ id: userId }, JWT_ACCESS_TOKEN_SECRET, { + expiresIn: '1h', + }); + const refreshToken = jwt.sign({ id: userId }, JWT_REFRESH_TOKEN_SECRET, { + expiresIn: '1d', + }); + return { accessToken, refreshToken }; +} + +function verifyAccessToken(token) { + const decoded = jwt.verify(token, JWT_ACCESS_TOKEN_SECRET); + return { userId: decoded.id }; +} + +function verifyRefreshToken(token) { + const decoded = jwt.verify(token, JWT_REFRESH_TOKEN_SECRET); + return { userId: decoded.id }; +} + +export { generateTokens, verifyAccessToken, verifyRefreshToken }; diff --git a/sprint-mission-4/middlewares/auth.middleware.js b/sprint-mission-4/middlewares/auth.middleware.js new file mode 100644 index 00000000..f6ec7757 --- /dev/null +++ b/sprint-mission-4/middlewares/auth.middleware.js @@ -0,0 +1,55 @@ +import { verifyAccessToken } from '../lib/token.js'; +import prisma from '../lib/prisma.js'; +import { ACCESS_TOKEN_COOKIE_NAME } from '../lib/constants.js'; + +export const authMiddleware = async (req, res, next) => { + try { + const accessToken = req.cookies[ACCESS_TOKEN_COOKIE_NAME]; + if (!accessToken) { + throw new Error('No access token found'); + } + + const { userId } = verifyAccessToken(accessToken); + if (!userId) { + throw new Error('Invalid access token'); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + req.user = user; + next(); + } catch (error) { + res.status(401).json({ message: 'Unauthorized: ' + error.message }); + } +}; + +export const softAuthMiddleware = async (req, res, next) => { + try { + const accessToken = req.cookies[ACCESS_TOKEN_COOKIE_NAME]; + if (!accessToken) { + return next(); + } + + const { userId } = verifyAccessToken(accessToken); + if (!userId) { + return next(); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (user) { + req.user = user; + } + next(); + } catch (error) { + next(); + } +}; diff --git a/sprint-mission-4/package.json b/sprint-mission-4/package.json new file mode 100644 index 00000000..b3e1813d --- /dev/null +++ b/sprint-mission-4/package.json @@ -0,0 +1,18 @@ +{ + "type": "module", + "scripts": { + "postinstall": "npx prisma generate && npx prisma migrate dev", + "start": "node app.js" + }, + "dependencies": { + "@prisma/client": "^6.4.0", + "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "prisma": "^6.4.0" + } +} diff --git a/sprint-mission-4/prisma/migrations/20250220102901_/migration.sql b/sprint-mission-4/prisma/migrations/20250220102901_/migration.sql new file mode 100644 index 00000000..294e2313 --- /dev/null +++ b/sprint-mission-4/prisma/migrations/20250220102901_/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "content" TEXT NOT NULL, + "authorId" INTEGER NOT NULL, + CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/sprint-mission-4/prisma/migrations/20251124112513_/migration.sql b/sprint-mission-4/prisma/migrations/20251124112513_/migration.sql new file mode 100644 index 00000000..16aebf29 --- /dev/null +++ b/sprint-mission-4/prisma/migrations/20251124112513_/migration.sql @@ -0,0 +1,113 @@ +/* + Warnings: + + - You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. + - Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `nickname` 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. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "Post"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "Product" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "price" INTEGER NOT NULL, + "image" TEXT, + "authorId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Product_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Article" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "image" TEXT, + "authorId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "content" TEXT NOT NULL, + "authorId" INTEGER NOT NULL, + "productId" INTEGER, + "articleId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Comment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Comment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "token" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_ProductLikes" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + CONSTRAINT "_ProductLikes_A_fkey" FOREIGN KEY ("A") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_ProductLikes_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_ArticleLikes" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + CONSTRAINT "_ArticleLikes_A_fkey" FOREIGN KEY ("A") REFERENCES "Article" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_ArticleLikes_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "nickname" TEXT NOT NULL, + "image" TEXT, + "password" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("id", "password") SELECT "id", "password" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ProductLikes_AB_unique" ON "_ProductLikes"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ProductLikes_B_index" ON "_ProductLikes"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ArticleLikes_AB_unique" ON "_ArticleLikes"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ArticleLikes_B_index" ON "_ArticleLikes"("B"); diff --git a/sprint-mission-4/prisma/migrations/20251125043522_add_post_model/migration.sql b/sprint-mission-4/prisma/migrations/20251125043522_add_post_model/migration.sql new file mode 100644 index 00000000..145b4723 --- /dev/null +++ b/sprint-mission-4/prisma/migrations/20251125043522_add_post_model/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "image" TEXT, + "authorId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_LikedPosts" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + CONSTRAINT "_LikedPosts_A_fkey" FOREIGN KEY ("A") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_LikedPosts_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "_LikedPosts_AB_unique" ON "_LikedPosts"("A", "B"); + +-- CreateIndex +CREATE INDEX "_LikedPosts_B_index" ON "_LikedPosts"("B"); diff --git a/sprint-mission-4/prisma/migrations/migration_lock.toml b/sprint-mission-4/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..e1640d1f --- /dev/null +++ b/sprint-mission-4/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" \ No newline at end of file diff --git a/sprint-mission-4/prisma/schema.prisma b/sprint-mission-4/prisma/schema.prisma new file mode 100644 index 00000000..0f50403e --- /dev/null +++ b/sprint-mission-4/prisma/schema.prisma @@ -0,0 +1,104 @@ +// prisma/schema.prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:../dev.db" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + nickname String + image String? + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + products Product[] + articles Article[] + posts Post[] @relation("Posts") + comments Comment[] + refreshTokens RefreshToken[] + + // Likes (Implicit Many-to-Many) + likedProducts Product[] @relation("ProductLikes") + likedArticles Article[] @relation("ArticleLikes") + likedPosts Post[] @relation("LikedPosts") +} + +model Product { + id Int @id @default(autoincrement()) + title String + description String + price Int + categoryName String? // 카테고리 이름 필드 추가 + image String? + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + category Category? @relation(fields: [categoryName], references: [name]) + + comments Comment[] + likedBy User[] @relation("ProductLikes") +} + +model Category { + name String @id @unique + product Product[] +} + +model Article { + id Int @id @default(autoincrement()) + title String + content String + image String? + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + comments Comment[] + likedBy User[] @relation("ArticleLikes") +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String + image String? + author User @relation("Posts", fields: [authorId], references: [id], onDelete: Cascade) + authorId Int + likedBy User[] @relation("LikedPosts") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 만약 Post에도 댓글 기능이 필요하다면 아래 관계를 추가합니다. + // comments Comment[] +} + +model Comment { + id Int @id @default(autoincrement()) + content String + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + productId Int? + product Product? @relation(fields: [productId], references: [id], onDelete: Cascade) + articleId Int? + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model RefreshToken { + id Int @id @default(autoincrement()) + token String @unique + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/sprint-mission-4/routers/articles.router.js b/sprint-mission-4/routers/articles.router.js new file mode 100644 index 00000000..bf1d0340 --- /dev/null +++ b/sprint-mission-4/routers/articles.router.js @@ -0,0 +1,252 @@ +import express from 'express'; +import prisma from '../lib/prisma.js'; +import { + authMiddleware, + softAuthMiddleware, +} from '../middlewares/auth.middleware.js'; + +const router = express.Router(); + +// Create Article +router.post('/', authMiddleware, async (req, res) => { + try { + const { title, content, image } = req.body; + const userId = req.user.id; + + if (!title || !content) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + const article = await prisma.article.create({ + data: { + title, + content, + image, + authorId: userId, + }, + }); + + res.status(201).json(article); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// List Articles +router.get('/', softAuthMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, keyword } = req.query; + const skip = (page - 1) * pageSize; + + const where = keyword + ? { + OR: [ + { title: { contains: keyword } }, + { content: { contains: keyword } }, + ], + } + : {}; + + const articles = await prisma.article.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + include: { + author: { + select: { id: true, nickname: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + // Optimized approach for isLiked in list + let likedArticleIds = new Set(); + if (req.user) { + const userLikes = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { likedArticles: { select: { id: true } } }, + }); + if (userLikes) { + likedArticleIds = new Set(userLikes.likedArticles.map((a) => a.id)); + } + } + + const result = articles.map((article) => ({ + ...article, + likeCount: article._count.likedBy, + isLiked: likedArticleIds.has(article.id), + })); + + res.json(result); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Get Article Detail +router.get('/:id', softAuthMiddleware, async (req, res) => { + try { + const { id } = req.params; + const articleId = parseInt(id); + + const article = await prisma.article.findUnique({ + where: { id: articleId }, + include: { + author: { + select: { id: true, nickname: true, image: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + if (!article) { + return res.status(404).json({ message: 'Article not found' }); + } + + let isLiked = false; + if (req.user) { + const likeCheck = await prisma.article.findFirst({ + where: { + id: articleId, + likedBy: { + some: { id: req.user.id }, + }, + }, + }); + isLiked = !!likeCheck; + } + + res.json({ + ...article, + likeCount: article._count.likedBy, + isLiked, + }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Update Article +router.patch('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { title, content, image } = req.body; + const userId = req.user.id; + const articleId = parseInt(id); + + const article = await prisma.article.findUnique({ + where: { id: articleId }, + }); + if (!article) { + return res.status(404).json({ message: 'Article not found' }); + } + + if (article.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const updatedArticle = await prisma.article.update({ + where: { id: articleId }, + data: { + title, + content, + image, + }, + }); + + res.json(updatedArticle); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Delete Article +router.delete('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const articleId = parseInt(id); + + const article = await prisma.article.findUnique({ + where: { id: articleId }, + }); + if (!article) { + return res.status(404).json({ message: 'Article not found' }); + } + + if (article.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + await prisma.article.delete({ where: { id: articleId } }); + + res.json({ message: 'Article deleted successfully' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Toggle Like +router.post('/:id/likes', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const articleId = parseInt(id); + + const article = await prisma.article.findUnique({ + where: { id: articleId }, + }); + if (!article) { + return res.status(404).json({ message: 'Article not found' }); + } + + // Check if already liked + const isLiked = await prisma.article.findFirst({ + where: { + id: articleId, + likedBy: { + some: { id: userId }, + }, + }, + }); + + if (isLiked) { + // Unlike + await prisma.article.update({ + where: { id: articleId }, + data: { + likedBy: { + disconnect: { id: userId }, + }, + }, + }); + res.json({ message: 'Unliked', isLiked: false }); + } else { + // Like + await prisma.article.update({ + where: { id: articleId }, + data: { + likedBy: { + connect: { id: userId }, + }, + }, + }); + res.json({ message: 'Liked', isLiked: true }); + } + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/sprint-mission-4/routers/auth.router.js b/sprint-mission-4/routers/auth.router.js new file mode 100644 index 00000000..e79754e5 --- /dev/null +++ b/sprint-mission-4/routers/auth.router.js @@ -0,0 +1,173 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import prisma from '../lib/prisma.js'; +import { generateTokens, verifyRefreshToken } from '../lib/token.js'; +import { + ACCESS_TOKEN_COOKIE_NAME, + REFRESH_TOKEN_COOKIE_NAME, + NODE_ENV, +} from '../lib/constants.js'; + +const router = express.Router(); + +// Signup +router.post('/signup', async (req, res) => { + try { + const { email, nickname, password, image } = req.body; + + if (!email || !nickname || !password) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + return res.status(409).json({ message: 'Email already exists' }); + } + + const hashedPassword = await bcrypt.hash(password, 12); + + const user = await prisma.user.create({ + data: { + email, + nickname, + password: hashedPassword, + image, + }, + }); + + // Don't return password + const { password: _, ...userWithoutPassword } = user; + + res.status(201).json(userWithoutPassword); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Login +router.post('/login', async (req, res) => { + try { + // 1. 요청 본문(body)에서 email과 password를 가져옵니다. + const { email, password } = req.body; + + // 2. 유효성 검사: email 또는 password가 없는 경우 에러 처리 + if (!email || !password) { + return res + .status(400) + .json({ message: '이메일과 비밀번호는 필수 입력 항목입니다.' }); + } + + // 3. email로 사용자를 찾습니다. + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return res + .status(401) + .json({ message: '인증 정보가 유효하지 않습니다.' }); + } + + // 4. 비밀번호를 비교합니다. + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return res + .status(401) + .json({ message: '인증 정보가 유효하지 않습니다.' }); + } + + // 5. 토큰을 생성합니다. + const { accessToken, refreshToken } = generateTokens(user.id); + + // 6. Refresh Token을 데이터베이스에 저장합니다. + await prisma.refreshToken.create({ + data: { + token: refreshToken, + userId: user.id, + }, + }); + + // 7. 토큰을 쿠키에 담아 응답합니다. + const cookieOptions = { + httpOnly: true, + secure: NODE_ENV === 'production', + sameSite: 'lax', + }; + res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, cookieOptions); + res.cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieOptions); + + res.json({ message: '로그인에 성공했습니다.' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Refresh Token +router.post('/refresh', async (req, res) => { + try { + const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE_NAME]; + if (!refreshToken) { + return res.status(401).json({ message: 'No refresh token provided' }); + } + + const { userId } = verifyRefreshToken(refreshToken); + + // Check if refresh token exists in DB + const savedToken = await prisma.refreshToken.findUnique({ + where: { token: refreshToken }, + }); + + if (!savedToken) { + return res.status(401).json({ message: 'Invalid refresh token' }); + } + + // Generate new tokens + const { accessToken, refreshToken: newRefreshToken } = + generateTokens(userId); + + // Rotate refresh token (delete old, create new) + // Transaction to ensure atomicity + await prisma.$transaction([ + prisma.refreshToken.delete({ where: { token: refreshToken } }), + prisma.refreshToken.create({ + data: { + token: newRefreshToken, + userId, + }, + }), + ]); + + res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, { httpOnly: true }); + res.cookie(REFRESH_TOKEN_COOKIE_NAME, newRefreshToken, { httpOnly: true }); + + res.json({ message: 'Token refreshed' }); + } catch (error) { + console.error(error); + res.status(401).json({ message: 'Invalid or expired refresh token' }); + } +}); + +// Logout +router.post('/logout', async (req, res) => { + try { + const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE_NAME]; + if (refreshToken) { + // Delete the token from DB only if it exists + await prisma.refreshToken + .delete({ where: { token: refreshToken } }) + .catch(() => {}); // Ignore errors if token is not found + } + + res.clearCookie(ACCESS_TOKEN_COOKIE_NAME); + res.clearCookie(REFRESH_TOKEN_COOKIE_NAME); + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/sprint-mission-4/routers/comments.router.js b/sprint-mission-4/routers/comments.router.js new file mode 100644 index 00000000..45f3a8aa --- /dev/null +++ b/sprint-mission-4/routers/comments.router.js @@ -0,0 +1,161 @@ +import express from 'express'; +import prisma from '../lib/prisma.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; + +const router = express.Router(); + +// Create Comment +router.post('/', authMiddleware, async (req, res) => { + try { + const { content, productId, articleId } = req.body; + const userId = req.user.id; + + if (!content) { + return res.status(400).json({ message: 'Content is required' }); + } + + if (!productId && !articleId) { + return res + .status(400) + .json({ message: 'ProductId or ArticleId is required' }); + } + + if (productId && articleId) { + return res + .status(400) + .json({ message: 'Cannot comment on both Product and Article' }); + } + + // Check if target exists + if (productId) { + const product = await prisma.product.findUnique({ + where: { id: parseInt(productId) }, + }); + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + } + + if (articleId) { + const article = await prisma.article.findUnique({ + where: { id: parseInt(articleId) }, + }); + if (!article) { + return res.status(404).json({ message: 'Article not found' }); + } + } + + const comment = await prisma.comment.create({ + data: { + content, + authorId: userId, + productId: productId ? parseInt(productId) : null, + articleId: articleId ? parseInt(articleId) : null, + }, + }); + + res.status(201).json(comment); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// List Comments +router.get('/', async (req, res) => { + try { + const { productId, articleId, page = 1, pageSize = 10 } = req.query; + const skip = (page - 1) * pageSize; + + if (!productId && !articleId) { + return res + .status(400) + .json({ message: 'ProductId or ArticleId is required' }); + } + + const where = {}; + if (productId) where.productId = parseInt(productId); + if (articleId) where.articleId = parseInt(articleId); + + const comments = await prisma.comment.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + include: { + author: { + select: { id: true, nickname: true, image: true }, + }, + }, + }); + + res.json(comments); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Update Comment +router.patch('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { content } = req.body; + const userId = req.user.id; + const commentId = parseInt(id); + + if (!content) { + return res.status(400).json({ message: 'Content is required' }); + } + + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + if (!comment) { + return res.status(404).json({ message: 'Comment not found' }); + } + + if (comment.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const updatedComment = await prisma.comment.update({ + where: { id: commentId }, + data: { content }, + }); + + res.json(updatedComment); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Delete Comment +router.delete('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const commentId = parseInt(id); + + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + if (!comment) { + return res.status(404).json({ message: 'Comment not found' }); + } + + if (comment.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + await prisma.comment.delete({ where: { id: commentId } }); + + res.json({ message: 'Comment deleted successfully' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/sprint-mission-4/routers/index.js b/sprint-mission-4/routers/index.js new file mode 100644 index 00000000..b8a62e96 --- /dev/null +++ b/sprint-mission-4/routers/index.js @@ -0,0 +1,10 @@ +import express from 'express'; +import authRouter from './authRouter.js'; +import postRouter from './postRouter.js'; + +const router = express.Router(); + +router.use(authRouter); +router.use(postRouter); + +export default router; \ No newline at end of file diff --git a/sprint-mission-4/routers/posts.router.js b/sprint-mission-4/routers/posts.router.js new file mode 100644 index 00000000..2f51101a --- /dev/null +++ b/sprint-mission-4/routers/posts.router.js @@ -0,0 +1,247 @@ +import express from 'express'; +import prisma from '../lib/prisma.js'; +import { + authMiddleware, + softAuthMiddleware, +} from '../middlewares/auth.middleware.js'; + +const router = express.Router(); + +// Create Post +router.post('/', authMiddleware, async (req, res) => { + try { + const { title, content, image } = req.body; + const userId = req.user.id; + + if (!title || !content) { + return res.status(400).json({ message: '제목과 내용은 필수입니다.' }); + } + + // Prisma 스키마에 'Post' 모델이 있다고 가정합니다. + const post = await prisma.post.create({ + data: { + title, + content, + image, + authorId: userId, + }, + }); + + res.status(201).json(post); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// List Posts +router.get('/', softAuthMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, keyword } = req.query; + const skip = (page - 1) * pageSize; + + const where = keyword + ? { + OR: [ + { title: { contains: keyword } }, + { content: { contains: keyword } }, + ], + } + : {}; + + const posts = await prisma.post.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + include: { + author: { + select: { id: true, nickname: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + let likedPostIds = new Set(); + if (req.user) { + const userLikes = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { likedPosts: { select: { id: true } } }, // 'likedPosts' 관계가 필요합니다. + }); + if (userLikes) { + likedPostIds = new Set(userLikes.likedPosts.map((p) => p.id)); + } + } + + const result = posts.map((post) => ({ + ...post, + likeCount: post._count.likedBy, + isLiked: likedPostIds.has(post.id), + })); + + res.json(result); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Get Post Detail +router.get('/:id', softAuthMiddleware, async (req, res) => { + try { + const { id } = req.params; + const postId = parseInt(id); + + const post = await prisma.post.findUnique({ + where: { id: postId }, + include: { + author: { + select: { id: true, nickname: true, image: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + if (!post) { + return res.status(404).json({ message: '게시물을 찾을 수 없습니다.' }); + } + + let isLiked = false; + if (req.user) { + const likeCheck = await prisma.post.findFirst({ + where: { + id: postId, + likedBy: { + some: { id: req.user.id }, + }, + }, + }); + isLiked = !!likeCheck; + } + + res.json({ + ...post, + likeCount: post._count.likedBy, + isLiked, + }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Update Post +router.patch('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { title, content, image } = req.body; + const userId = req.user.id; + const postId = parseInt(id); + + const post = await prisma.post.findUnique({ + where: { id: postId }, + }); + if (!post) { + return res.status(404).json({ message: '게시물을 찾을 수 없습니다.' }); + } + + if (post.authorId !== userId) { + return res.status(403).json({ message: '수정 권한이 없습니다.' }); + } + + const updatedPost = await prisma.post.update({ + where: { id: postId }, + data: { + title, + content, + image, + }, + }); + + res.json(updatedPost); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Delete Post +router.delete('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const postId = parseInt(id); + + const post = await prisma.post.findUnique({ + where: { id: postId }, + }); + if (!post) { + return res.status(404).json({ message: '게시물을 찾을 수 없습니다.' }); + } + + if (post.authorId !== userId) { + return res.status(403).json({ message: '삭제 권한이 없습니다.' }); + } + + await prisma.post.delete({ where: { id: postId } }); + + res.json({ message: '게시물이 삭제되었습니다.' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Toggle Like +router.post('/:id/likes', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const postId = parseInt(id); + + const post = await prisma.post.findUnique({ + where: { id: postId }, + }); + if (!post) { + return res.status(404).json({ message: '게시물을 찾을 수 없습니다.' }); + } + + const isLiked = await prisma.post.findFirst({ + where: { + id: postId, + likedBy: { + some: { id: userId }, + }, + }, + }); + + if (isLiked) { + // Unlike + await prisma.post.update({ + where: { id: postId }, + data: { + likedBy: { disconnect: { id: userId } }, + }, + }); + res.json({ message: '좋아요를 취소했습니다.', isLiked: false }); + } else { + // Like + await prisma.post.update({ + where: { id: postId }, + data: { + likedBy: { connect: { id: userId } }, + }, + }); + res.json({ message: '좋아요를 눌렀습니다.', isLiked: true }); + } + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/sprint-mission-4/routers/products.router.js b/sprint-mission-4/routers/products.router.js new file mode 100644 index 00000000..15ab30fb --- /dev/null +++ b/sprint-mission-4/routers/products.router.js @@ -0,0 +1,271 @@ +import express from 'express'; +import prisma from '../lib/prisma.js'; +import { + authMiddleware, + softAuthMiddleware, +} from '../middlewares/auth.middleware.js'; + +const router = express.Router(); + +// Create Product +router.post('/', authMiddleware, async (req, res) => { + try { + const { title, description, price, image } = req.body; + const userId = req.user.id; + + if (!title || !description || !price) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + const product = await prisma.product.create({ + data: { + title, + description, + price: parseInt(price), + image, + authorId: userId, + }, + }); + + res.status(201).json(product); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// List Products +router.get('/', softAuthMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, keyword } = req.query; + const skip = (page - 1) * pageSize; + + const where = keyword + ? { + OR: [ + { title: { contains: keyword } }, + { description: { contains: keyword } }, + ], + } + : {}; + + const products = await prisma.product.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + include: { + author: { + select: { id: true, nickname: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + const productsWithLike = products.map((product) => { + let isLiked = false; + if (req.user) { + // This is inefficient for many products. + // Better to fetch liked IDs separately or use a subquery if possible. + // For simplicity/Prisma limitations, we might need to fetch user's liked products IDs first. + // Or include `likedBy` with `where` clause in the main query? + // Prisma `include` with `where` on relation is possible. + } + return { + ...product, + likeCount: product._count.likedBy, + isLiked, // We'll fix this below + }; + }); + + // Optimized approach for isLiked in list: + // Fetch user's liked product IDs if logged in + let likedProductIds = new Set(); + if (req.user) { + const userLikes = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { likedProducts: { select: { id: true } } }, + }); + if (userLikes) { + likedProductIds = new Set(userLikes.likedProducts.map((p) => p.id)); + } + } + + const result = products.map((product) => ({ + ...product, + likeCount: product._count.likedBy, + isLiked: likedProductIds.has(product.id), + })); + + res.json(result); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Get Product Detail +router.get('/:id', softAuthMiddleware, async (req, res) => { + try { + const { id } = req.params; + const productId = parseInt(id); + + const product = await prisma.product.findUnique({ + where: { id: productId }, + include: { + author: { + select: { id: true, nickname: true, image: true }, + }, + _count: { + select: { likedBy: true }, + }, + }, + }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + let isLiked = false; + if (req.user) { + const likeCheck = await prisma.product.findFirst({ + where: { + id: productId, + likedBy: { + some: { id: req.user.id }, + }, + }, + }); + isLiked = !!likeCheck; + } + + res.json({ + ...product, + likeCount: product._count.likedBy, + isLiked, + }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Update Product +router.patch('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { title, description, price, image } = req.body; + const userId = req.user.id; + const productId = parseInt(id); + + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + if (product.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const updatedProduct = await prisma.product.update({ + where: { id: productId }, + data: { + title, + description, + price: price ? parseInt(price) : undefined, + image, + }, + }); + + res.json(updatedProduct); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Delete Product +router.delete('/:id', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const productId = parseInt(id); + + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + if (product.authorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + await prisma.product.delete({ where: { id: productId } }); + + res.json({ message: 'Product deleted successfully' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Toggle Like +router.post('/:id/likes', authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const productId = parseInt(id); + + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + // Check if already liked + const isLiked = await prisma.product.findFirst({ + where: { + id: productId, + likedBy: { + some: { id: userId }, + }, + }, + }); + + if (isLiked) { + // Unlike + await prisma.product.update({ + where: { id: productId }, + data: { + likedBy: { + disconnect: { id: userId }, + }, + }, + }); + res.json({ message: 'Unliked', isLiked: false }); + } else { + // Like + await prisma.product.update({ + where: { id: productId }, + data: { + likedBy: { + connect: { id: userId }, + }, + }, + }); + res.json({ message: 'Liked', isLiked: true }); + } + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/sprint-mission-4/routers/users.router.js b/sprint-mission-4/routers/users.router.js new file mode 100644 index 00000000..ab411bfa --- /dev/null +++ b/sprint-mission-4/routers/users.router.js @@ -0,0 +1,144 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import prisma from '../lib/prisma.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; + +const router = express.Router(); + +// Helper function to exclude password from user object +const excludePassword = (user) => { + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; +}; + +// Get My Info +router.get('/me', authMiddleware, async (req, res) => { + try { + const user = req.user; + const userWithoutPassword = excludePassword(user); + res.json(userWithoutPassword); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Update My Info +router.patch('/me', authMiddleware, async (req, res) => { + try { + const { nickname, image } = req.body; + const userId = req.user.id; + + if (nickname === undefined && image === undefined) { + return res.status(400).json({ + message: 'At least one field (nickname or image) is required', + }); + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + nickname, + image, + }, + }); + + const userWithoutPassword = excludePassword(updatedUser); + res.json(userWithoutPassword); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Change Password +router.patch('/me/password', authMiddleware, async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + const userId = req.user.id; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + if (newPassword.length < 6) { + return res + .status(400) + .json({ message: 'New password must be at least 6 characters long' }); + } + + if (currentPassword === newPassword) { + return res + .status(400) + .json({ + message: 'New password cannot be the same as current password', + }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const isPasswordValid = await bcrypt.compare( + currentPassword, + user.password + ); + + if (!isPasswordValid) { + return res.status(401).json({ message: 'Invalid current password' }); + } + + const hashedNewPassword = await bcrypt.hash(newPassword, 12); + + await prisma.user.update({ + where: { id: userId }, + data: { + password: hashedNewPassword, + }, + }); + + res.json({ message: 'Password updated successfully' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Get My Products +router.get('/me/products', authMiddleware, async (req, res) => { + try { + const userId = req.user.id; + const products = await prisma.product.findMany({ + where: { authorId: userId }, + orderBy: { createdAt: 'desc' }, + }); + res.json(products); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// Get My Liked Products +router.get('/me/likes', authMiddleware, async (req, res) => { + try { + const userId = req.user.id; + // Using implicit many-to-many relation + const userWithLikes = await prisma.user.findUnique({ + where: { id: userId }, + include: { + likedProducts: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + res.json(userWithLikes.likedProducts); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router;