diff --git a/public/uploads/1762694665640-mepawpoqguh.webp b/public/uploads/1762694665640-mepawpoqguh.webp
new file mode 100644
index 0000000..bda5920
Binary files /dev/null and b/public/uploads/1762694665640-mepawpoqguh.webp differ
diff --git a/public/uploads/1762694722874-13r02smuxh0n.webp b/public/uploads/1762694722874-13r02smuxh0n.webp
new file mode 100644
index 0000000..bda5920
Binary files /dev/null and b/public/uploads/1762694722874-13r02smuxh0n.webp differ
diff --git a/public/uploads/1762694815229-575v2kyj72x.webp b/public/uploads/1762694815229-575v2kyj72x.webp
new file mode 100644
index 0000000..bda5920
Binary files /dev/null and b/public/uploads/1762694815229-575v2kyj72x.webp differ
diff --git a/public/uploads/1762695071878-s0a8nautp7d.webp b/public/uploads/1762695071878-s0a8nautp7d.webp
new file mode 100644
index 0000000..bda5920
Binary files /dev/null and b/public/uploads/1762695071878-s0a8nautp7d.webp differ
diff --git a/public/uploads/1762695378037-rbj4gzlxveq.webp b/public/uploads/1762695378037-rbj4gzlxveq.webp
new file mode 100644
index 0000000..bda5920
Binary files /dev/null and b/public/uploads/1762695378037-rbj4gzlxveq.webp differ
diff --git a/public/uploads/1762696083342-gwebeuwl0q4.webp b/public/uploads/1762696083342-gwebeuwl0q4.webp
new file mode 100644
index 0000000..3a3f4c2
Binary files /dev/null and b/public/uploads/1762696083342-gwebeuwl0q4.webp differ
diff --git a/public/uploads/1762696149731-1fom3wudm94.webp b/public/uploads/1762696149731-1fom3wudm94.webp
new file mode 100644
index 0000000..3429c96
Binary files /dev/null and b/public/uploads/1762696149731-1fom3wudm94.webp differ
diff --git a/src/app/api/auth/check-name/route.ts b/src/app/api/auth/check-name/route.ts
new file mode 100644
index 0000000..11f9967
--- /dev/null
+++ b/src/app/api/auth/check-name/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { nameSchema } from "@/lib/validation/auth";
+
+export async function GET(req: Request) {
+ const { searchParams } = new URL(req.url);
+ const nameRaw = searchParams.get("name") ?? "";
+ const name = nameRaw.trim();
+
+ const parsed = nameSchema.safeParse(name);
+ if (!parsed.success) {
+ const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식";
+ return NextResponse.json(
+ { error: { fieldErrors: { name: [firstMsg] } } },
+ { status: 400 }
+ );
+ }
+
+ const exists = await prisma.user.findFirst({ where: { name } });
+ return NextResponse.json({ available: !exists });
+}
+
+
diff --git a/src/app/api/auth/check-nickname/route.ts b/src/app/api/auth/check-nickname/route.ts
new file mode 100644
index 0000000..42f52a0
--- /dev/null
+++ b/src/app/api/auth/check-nickname/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { nicknameSchema } from "@/lib/validation/auth";
+
+export async function GET(req: Request) {
+ const { searchParams } = new URL(req.url);
+ const nicknameRaw = searchParams.get("nickname") ?? "";
+ const nickname = nicknameRaw.trim();
+
+ const parsed = nicknameSchema.safeParse(nickname);
+ if (!parsed.success) {
+ const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식";
+ return NextResponse.json(
+ { error: { fieldErrors: { nickname: [firstMsg] } } },
+ { status: 400 }
+ );
+ }
+
+ const exists = await prisma.user.findUnique({ where: { nickname } });
+ return NextResponse.json({ available: !exists });
+}
+
+
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 0bd7f46..61a83ef 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -12,11 +12,18 @@ export async function POST(req: Request) {
const body = await req.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success)
- return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
- const { nickname, password } = parsed.data;
- const user = await prisma.user.findUnique({ where: { nickname } });
+ return NextResponse.json(
+ { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
+ { status: 401 }
+ );
+ const { id, password } = parsed.data;
+ // DB에서는 로그인 아이디를 nickname 컬럼으로 보관
+ const user = await prisma.user.findUnique({ where: { nickname: id } });
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
- return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
+ return NextResponse.json(
+ { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
+ { status: 401 }
+ );
}
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
}
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 2c3c376..49faedd 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -8,15 +8,40 @@ export async function POST(req: Request) {
const parsed = registerSchema.safeParse(body);
if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
- const { nickname, name, password } = parsed.data;
- const exists = await prisma.user.findFirst({ where: { nickname } });
- if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
+ const { nickname, name, password, profileImage } = parsed.data as {
+ nickname: string;
+ name: string;
+ password: string;
+ profileImage?: string;
+ };
+ // 아이디(닉네임 필드와 구분)를 우선 검사
+ const nicknameExists = await prisma.user.findFirst({ where: { nickname } });
+ if (nicknameExists) {
+ return NextResponse.json(
+ { error: { fieldErrors: { nickname: ["이미 사용 중인 아이디입니다"] } } },
+ { status: 409 }
+ );
+ }
+ // 표시용 닉네임(name)도 유일해야 함
+ const nameExists = await prisma.user.findFirst({ where: { name } });
+ if (nameExists) {
+ return NextResponse.json(
+ { error: { fieldErrors: { name: ["이미 사용 중인 닉네임입니다"] } } },
+ { status: 409 }
+ );
+ }
const user = await prisma.user.create({
data: {
nickname,
name,
passwordHash: hashPassword(password),
agreementTermsAt: new Date(),
+ // 일부 환경에서 birth 컬럼이 NOT NULL 제약으로 남아있는 경우가 있어 안전 기본값을 기록
+ birth: new Date(0),
+ // 일부 환경에서 phone 컬럼이 NOT NULL+UNIQUE 제약으로 남아있는 경우가 있어
+ // 임시 유니크 플레이스홀더를 기록합니다. (후속 마이그레이션으로 NULL 허용 권장)
+ phone: `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
+ profileImage: profileImage || null,
},
select: { userId: true, nickname: true },
});
diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts
index f9b2285..534fbee 100644
--- a/src/app/api/auth/session/route.ts
+++ b/src/app/api/auth/session/route.ts
@@ -29,11 +29,18 @@ export async function POST(req: Request) {
const body = await req.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success)
- return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
- const { nickname, password } = parsed.data;
- const user = await prisma.user.findUnique({ where: { nickname } });
+ return NextResponse.json(
+ { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
+ { status: 401 }
+ );
+ const { id, password } = parsed.data;
+ // DB에서는 로그인 아이디를 nickname 컬럼으로 보관
+ const user = await prisma.user.findUnique({ where: { nickname: id } });
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
- return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
+ return NextResponse.json(
+ { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
+ { status: 401 }
+ );
}
// 사용자의 관리자 권한 여부 확인
let isAdmin = false;
diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts
new file mode 100644
index 0000000..8de062a
--- /dev/null
+++ b/src/app/api/me/password/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { getUserIdOrAdmin } from "@/lib/auth";
+import { verifyPassword, hashPassword } from "@/lib/password";
+
+export async function PUT(req: Request) {
+ const userId = await getUserIdOrAdmin(req);
+ if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ try {
+ const body = await req.json();
+ const currentPassword: string | undefined = body?.currentPassword;
+ const newPassword: string | undefined = body?.newPassword;
+ if (!currentPassword || !newPassword) {
+ return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 });
+ }
+ if (newPassword.length < 8 || newPassword.length > 100) {
+ return NextResponse.json({ error: "password length invalid" }, { status: 400 });
+ }
+ const user = await prisma.user.findUnique({
+ where: { userId },
+ select: { passwordHash: true },
+ });
+ if (!user || !user.passwordHash) {
+ return NextResponse.json({ error: "invalid user" }, { status: 400 });
+ }
+ if (!verifyPassword(currentPassword, user.passwordHash)) {
+ return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 });
+ }
+ if (verifyPassword(newPassword, user.passwordHash)) {
+ // 새 비밀번호가 기존과 동일
+ return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 });
+ }
+ await prisma.user.update({
+ where: { userId },
+ data: { passwordHash: hashPassword(newPassword) },
+ });
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Bad Request" }, { status: 400 });
+ }
+}
+
+
diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts
new file mode 100644
index 0000000..bb17a37
--- /dev/null
+++ b/src/app/api/messages/route.ts
@@ -0,0 +1,77 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { z } from "zod";
+import { getUserIdFromRequest } from "@/lib/auth";
+
+const sendSchema = z.object({
+ receiverId: z.string().min(1, "수신자 정보가 없습니다"),
+ body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"),
+});
+
+export async function POST(req: Request) {
+ const senderId = getUserIdFromRequest(req);
+ if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ const contentType = req.headers.get("content-type") || "";
+ let data: any = {};
+ if (contentType.includes("application/json")) {
+ data = await req.json().catch(() => ({}));
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
+ const form = await req.formData();
+ data = Object.fromEntries(form.entries());
+ } else {
+ // 기본적으로 form 으로 간주
+ const form = await req.formData().catch(() => null);
+ if (form) data = Object.fromEntries(form.entries());
+ }
+ const parsed = sendSchema.safeParse(data);
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ const { receiverId, body } = parsed.data;
+ if (receiverId === senderId) {
+ return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 });
+ }
+ const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } });
+ if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 });
+ const message = await prisma.message.create({
+ data: { senderId, receiverId, body },
+ select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true },
+ });
+ return NextResponse.json({ message }, { status: 201 });
+}
+
+const listQuery = z.object({
+ box: z.enum(["received", "sent"]).default("received").optional(),
+ page: z.coerce.number().min(1).default(1).optional(),
+ pageSize: z.coerce.number().min(1).max(100).default(20).optional(),
+});
+
+export async function GET(req: Request) {
+ const userId = getUserIdFromRequest(req);
+ if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ const { searchParams } = new URL(req.url);
+ const parsed = listQuery.safeParse(Object.fromEntries(searchParams));
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ const { box = "received", page = 1, pageSize = 20 } = parsed.data;
+ const where =
+ box === "received"
+ ? { receiverId: userId }
+ : { senderId: userId };
+ const [total, items] = await Promise.all([
+ prisma.message.count({ where }),
+ prisma.message.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ select: {
+ id: true,
+ body: true,
+ createdAt: true,
+ sender: { select: { userId: true, nickname: true } },
+ receiver: { select: { userId: true, nickname: true } },
+ },
+ }),
+ ]);
+ return NextResponse.json({ total, page, pageSize, items });
+}
+
+
diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts
index e658a99..e46f867 100644
--- a/src/app/api/posts/[id]/comments/route.ts
+++ b/src/app/api/posts/[id]/comments/route.ts
@@ -1,12 +1,19 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
+import { getUserIdFromRequest } from "@/lib/auth";
-export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
+export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
-
+ const requesterId = getUserIdFromRequest(req);
+ const post = await prisma.post.findUnique({
+ where: { id },
+ select: { authorId: true },
+ });
+ const postAuthorId = post?.authorId ?? null;
+
// 최상위 댓글만 가져오기
const topComments = await prisma.comment.findMany({
- where: {
+ where: {
postId: id,
parentId: null, // 최상위 댓글만
},
@@ -27,26 +34,36 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
},
},
});
-
+
// 재귀적으로 댓글 구조 변환
- const transformComment = (comment: any) => ({
- id: comment.id,
- parentId: comment.parentId,
- depth: comment.depth,
- content: comment.isSecret ? "비밀댓글입니다." : comment.content,
- isAnonymous: comment.isAnonymous,
- isSecret: comment.isSecret,
- author: comment.author ? {
- userId: comment.author.userId,
- nickname: comment.author.nickname,
- profileImage: comment.author.profileImage,
- } : null,
- anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- replies: comment.replies ? comment.replies.map(transformComment) : [],
- });
-
+ const transformComment = (comment: any): any => {
+ const commentAuthorId: string | null = comment.author?.userId ?? null;
+ const canViewSecret =
+ !comment.isSecret ||
+ (requesterId != null &&
+ (requesterId === commentAuthorId || requesterId === postAuthorId));
+
+ return {
+ id: comment.id,
+ parentId: comment.parentId,
+ depth: comment.depth,
+ content: canViewSecret ? comment.content : "비밀댓글입니다.",
+ isAnonymous: comment.isAnonymous,
+ isSecret: comment.isSecret,
+ author: comment.author
+ ? {
+ userId: comment.author.userId,
+ nickname: comment.author.nickname,
+ profileImage: comment.author.profileImage,
+ }
+ : null,
+ anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ replies: comment.replies ? comment.replies.map(transformComment) : [],
+ };
+ };
+
const presented = topComments.map(transformComment);
return NextResponse.json({ comments: presented });
}
diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts
index 6085d36..3b4a743 100644
--- a/src/app/api/posts/[id]/route.ts
+++ b/src/app/api/posts/[id]/route.ts
@@ -9,6 +9,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
const post = await prisma.post.findUnique({
where: { id },
include: {
+ author: { select: { userId: true, nickname: true } },
board: { select: { id: true, name: true, slug: true } },
},
});
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
index dc9f7cc..23efee5 100644
--- a/src/app/api/posts/route.ts
+++ b/src/app/api/posts/route.ts
@@ -117,9 +117,10 @@ export async function GET(req: Request) {
title: true,
createdAt: true,
boardId: true,
+ board: { select: { id: true, name: true, slug: true } },
isPinned: true,
status: true,
- author: { select: { nickname: true } },
+ author: { select: { userId: true, nickname: true } },
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
postTags: { select: { tag: { select: { name: true, slug: true } } } },
},
diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx
index 2291810..485fed2 100644
--- a/src/app/components/AppHeader.tsx
+++ b/src/app/components/AppHeader.tsx
@@ -2,7 +2,7 @@
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
import Image from "next/image";
import Link from "next/link";
-import { SearchBar } from "@/app/components/SearchBar";
+import { SearchBar } from "./SearchBar";
import useSWR from "swr";
import { UserAvatar } from "@/app/components/UserAvatar";
import { GradeIcon } from "@/app/components/GradeIcon";
@@ -577,14 +577,24 @@ export function AppHeader() {
)}
-
{createdAt.toLocaleString()}
- )} +