Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ env/*
build

# misc
.vscode
.DS_Store
.env.local
.env.development.local
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"dev": "nodemon --env-file=./env/.env.development src/server.js",
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"seed": "node scripts/seed.js",
"format": "prettier --write src/**/*.js",
Expand All @@ -20,9 +20,15 @@
"npm": "^11.6.0"
},
"dependencies": {
"@prisma/client": "^6.16.2",
"@faker-js/faker": "^10.0.0",
"@prisma/client": "^6.16.2",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"fs": "0.0.1-security",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"path": "^0.12.7",
"zod": "^4.1.11"
},
"devDependencies": {
Expand Down
295 changes: 295 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions prisma/migrations/20251120061057_add_user/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
Warnings:

- You are about to alter the column `price` on the `Product` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Integer`.
- 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 `Comment` table without a default value. This is not possible if the table is not empty.
- Added the required column `authorId` to the `Product` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "public"."Article" ADD COLUMN "authorId" TEXT NOT NULL;

-- AlterTable
ALTER TABLE "public"."Comment" ADD COLUMN "authorId" TEXT NOT NULL;

-- AlterTable
ALTER TABLE "public"."Product" ADD COLUMN "authorId" TEXT NOT NULL,
ALTER COLUMN "price" SET DATA TYPE INTEGER;

-- CreateTable
CREATE TABLE "public"."User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"nickname" TEXT,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "public"."LikeArticle" (
"id" SERIAL NOT NULL,
"userId" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "LikeArticle_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "public"."LikeProduct" (
"id" SERIAL NOT NULL,
"userId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "LikeProduct_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");

-- CreateIndex
CREATE UNIQUE INDEX "LikeArticle_userId_articleId_key" ON "public"."LikeArticle"("userId", "articleId");

-- CreateIndex
CREATE UNIQUE INDEX "LikeProduct_userId_productId_key" ON "public"."LikeProduct"("userId", "productId");

-- AddForeignKey
ALTER TABLE "public"."Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."LikeArticle" ADD CONSTRAINT "LikeArticle_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."LikeArticle" ADD CONSTRAINT "LikeArticle_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "public"."Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."Product" ADD CONSTRAINT "Product_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."LikeProduct" ADD CONSTRAINT "LikeProduct_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."LikeProduct" ADD CONSTRAINT "LikeProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "public"."Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."User" ADD COLUMN "refreshToken" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Product" ADD COLUMN "image" TEXT[];
49 changes: 47 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,74 @@

generator client {
provider = "prisma-client-js"

}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id String @id @default(uuid())
email String @unique
nickname String?
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshToken String?
articles Article[]
products Product[]
comments Comment[]
likedArticles LikeArticle[]
likedProducts LikeProduct[]
}

model Article {
id String @id @default(cuid())
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
likes LikeArticle[]
}

model LikeArticle {
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
articleId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, articleId])
}

model Product {
id String @id @default(cuid())
name String
description String @db.Text
price Decimal
tags String[]
price Int
tags String[]
image String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
likes LikeProduct[]
}

model LikeProduct {
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, productId])
}

enum ParentType {
Expand All @@ -43,6 +86,8 @@ model Comment {
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
parent ParentType
// Article의 댓글인 경우
articleId String?
Expand Down
92 changes: 0 additions & 92 deletions scripts/seed.js

This file was deleted.

2 changes: 2 additions & 0 deletions src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const envSchema = z.object({
PORT: z.coerce.number().min(1000).max(65535),
DATABASE_URL: z.string().startsWith('postgresql://'),
FRONT_URL: z.string(),
JWT_SECRET: z.string(),
});

const parseEnvironment = () => {
Expand All @@ -16,6 +17,7 @@ const parseEnvironment = () => {
PORT: process.env.PORT,
DATABASE_URL: process.env.DATABASE_URL,
FRONT_URL: process.env.FRONT_URL,
JWT_SECRET: process.env.JWT_SECRET,
});
} catch (err) {
if (err instanceof z.ZodError) {
Expand Down
72 changes: 72 additions & 0 deletions src/middlewares/auth.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import jwt from 'jsonwebtoken';
import usersRepository from '../repository/users.repository.js';
import { UnauthorizedException } from '../err/unauthorizedException.js';
import { config } from '../config/config.js';

const verifyToken = (token) => {
try {
return jwt.verify(token, config.JWT_SECRET);
} catch (err) {
return null;
}
};

export const authMiddleware = async (req, res, next) => {
try {
const { authorization } = req.cookies;
if (!authorization) {
throw new UnauthorizedException('인증 정보가 없습니다.');
}
const [tokenType, token] = authorization.split(' ');
if (tokenType !== 'Bearer' || !token) {
throw new UnauthorizedException('지원하지 않는 인증 방식입니다.');
}

let decoded = verifyToken(token);

// Access Token이 만료된 경우
if (!decoded) {
const { refreshToken: refreshTokenWithBearer } = req.cookies;
if (!refreshTokenWithBearer) {
throw new UnauthorizedException('인증 정보가 만료되었습니다.');
}

const [refreshTokenType, refreshToken] =
refreshTokenWithBearer.split(' ');
if (refreshTokenType !== 'Bearer' || !refreshToken) {
throw new UnauthorizedException('지원하지 않는 인증 방식입니다.');
}

const decodedRefreshToken = verifyToken(refreshToken);
if (!decodedRefreshToken) {
throw new UnauthorizedException('인증 정보가 만료되었습니다.');
}

const user = await usersRepository.findUserById(
decodedRefreshToken.userId,
);
if (!user || user.refreshToken !== refreshToken) {
throw new UnauthorizedException('인증 정보가 유효하지 않습니다.');
}

// 새로운 Access Token 발급
const newAccessToken = jwt.sign({ userId: user.id }, config.JWT_SECRET, {
expiresIn: '6h',
});

res.cookie('authorization', `Bearer ${newAccessToken}`);
decoded = verifyToken(newAccessToken);
}

const user = await usersRepository.findUserById(decoded.userId);
if (!user) {
throw new UnauthorizedException(
'인증 정보와 일치하는 사용자가 없습니다.',
);
}
req.user = user;
next();
Comment on lines +15 to +68
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

} catch (err) {
next(err);
}
};
10 changes: 10 additions & 0 deletions src/middlewares/pagination.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const parsePagination = (req, res, next) => {
const pageSize = parseInt(req.query.pageSize, 10) || 10;
const cursor = req.query.cursor;

req.pagination = {
take: pageSize,
cursor,
};
next();
};
Loading