From b068750ae0588b5d69e71a305de538e467b92e7b Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:06:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20room=20DTO=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomMode, RoomDto, CreateRoomRequest, RoomListResponse Co-Authored-By: Claude Sonnet 4.6 --- src/types/room.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/types/room.ts diff --git a/src/types/room.ts b/src/types/room.ts new file mode 100644 index 0000000..b9d5f17 --- /dev/null +++ b/src/types/room.ts @@ -0,0 +1,46 @@ +import type { CategoryGroup } from './common' + +// DB RoomMode enum @map 값 기준 +export type RoomMode = + | 'ai_chat' + | 'invite_ready' + | 'one_to_one' + | 'closed' + | 'expired' + | 'deleted' + +// ============================================================ +// Response DTOs +// ============================================================ + +export interface RoomDto { + id: string + roomNo: string + creatorUserId: string + categoryGroup: CategoryGroup + roomMode: RoomMode + inviteCreatedAt: string | null + expiresAt: string | null + closedAt: string | null + createdAt: string + updatedAt: string +} + +// ============================================================ +// Request Types +// ============================================================ + +export interface CreateRoomRequest { + categoryGroup: CategoryGroup +} + +// ============================================================ +// Query / Response +// ============================================================ + +export interface RoomListResponse { + rooms: RoomDto[] + total: number + page: number + limit: number +} From 4b3079fd66de2760c0f8eaf8e00bb20ccfa081c4 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:06:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=C2=B7=EC=83=9D=EC=84=B1=C2=B7=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=A2=85=EB=A3=8C=C2=B7?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/rooms — 내 방 목록 조회 (페이지네이션) - POST /api/v1/rooms — AI 대화방 생성 (roomMode = ai_chat) - GET /api/v1/rooms/:id — 방 상세 조회 - POST /api/v1/rooms/:id/close — 방 종료 (closedAt + roomMode = closed) - DELETE /api/v1/rooms/:id — 방 소프트 삭제 (deletedAt + roomMode = deleted) Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/v1/rooms/[id]/close/route.ts | 71 ++++++++++ src/app/api/v1/rooms/[id]/route.ts | 138 +++++++++++++++++++ src/app/api/v1/rooms/route.ts | 161 +++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/app/api/v1/rooms/[id]/close/route.ts create mode 100644 src/app/api/v1/rooms/[id]/route.ts create mode 100644 src/app/api/v1/rooms/route.ts diff --git a/src/app/api/v1/rooms/[id]/close/route.ts b/src/app/api/v1/rooms/[id]/close/route.ts new file mode 100644 index 0000000..5e174f8 --- /dev/null +++ b/src/app/api/v1/rooms/[id]/close/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getSessionUserId } from '@/lib/auth/session' +import type { ApiResponse } from '@/types/common' + +const CLOSEABLE_MODES = ['AI_CHAT', 'INVITE_READY', 'ONE_TO_ONE'] as const + +// POST /api/v1/rooms/:id/close +// 방 종료. 생성자만 가능. closed/expired/deleted 상태에서는 불가 +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, + { status: 401 }, + ) + } + + const { id } = await params + + try { + const room = await prisma.disputeRoom.findFirst({ + where: { id, deletedAt: null }, + }) + if (!room) { + return NextResponse.json( + { success: false, error: { code: 'ROOM_NOT_FOUND', message: '방을 찾을 수 없습니다.' } }, + { status: 404 }, + ) + } + + if (room.creatorUserId !== userId) { + return NextResponse.json( + { success: false, error: { code: 'FORBIDDEN', message: '방을 종료할 권한이 없습니다.' } }, + { status: 403 }, + ) + } + + if (!CLOSEABLE_MODES.includes(room.roomMode as (typeof CLOSEABLE_MODES)[number])) { + return NextResponse.json( + { + success: false, + error: { + code: 'ROOM_NOT_CLOSEABLE', + message: '현재 상태에서는 방을 종료할 수 없습니다.', + details: `현재 방 상태: ${room.roomMode.toLowerCase()}`, + }, + }, + { status: 422 }, + ) + } + + await prisma.disputeRoom.update({ + where: { id }, + data: { roomMode: 'CLOSED', closedAt: new Date() }, + }) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json( + { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/v1/rooms/[id]/route.ts b/src/app/api/v1/rooms/[id]/route.ts new file mode 100644 index 0000000..8dad504 --- /dev/null +++ b/src/app/api/v1/rooms/[id]/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { type RoomMode as PrismaRoomMode, type CategoryGroup as PrismaCategoryGroup } from '@prisma/client' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getSessionUserId } from '@/lib/auth/session' +import type { ApiResponse, CategoryGroup } from '@/types/common' +import type { RoomDto, RoomMode } from '@/types/room' + +function toRoomDto(room: { + id: string + roomNo: string + creatorUserId: string + categoryGroup: PrismaCategoryGroup + roomMode: PrismaRoomMode + inviteCreatedAt: Date | null + expiresAt: Date | null + closedAt: Date | null + createdAt: Date + updatedAt: Date +}): RoomDto { + return { + id: room.id, + roomNo: room.roomNo, + creatorUserId: room.creatorUserId, + categoryGroup: room.categoryGroup.toLowerCase() as CategoryGroup, + roomMode: room.roomMode.toLowerCase() as RoomMode, + inviteCreatedAt: room.inviteCreatedAt?.toISOString() ?? null, + expiresAt: room.expiresAt?.toISOString() ?? null, + closedAt: room.closedAt?.toISOString() ?? null, + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + } +} + +function hasAccess(room: { creatorUserId: string; roomMode: PrismaRoomMode; dispute: { participants: { userId: string }[] } | null }, userId: string): boolean { + if (room.roomMode === 'AI_CHAT' || room.roomMode === 'INVITE_READY') { + return room.creatorUserId === userId + } + return ( + room.creatorUserId === userId || + (room.dispute?.participants.some((p) => p.userId === userId) ?? false) + ) +} + +// GET /api/v1/rooms/:id +// 방 상세 조회 +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, + { status: 401 }, + ) + } + + const { id } = await params + + try { + const room = await prisma.disputeRoom.findFirst({ + where: { id, deletedAt: null }, + include: { dispute: { include: { participants: true } } }, + }) + if (!room) { + return NextResponse.json( + { success: false, error: { code: 'ROOM_NOT_FOUND', message: '방을 찾을 수 없습니다.' } }, + { status: 404 }, + ) + } + + if (!hasAccess(room, userId)) { + return NextResponse.json( + { success: false, error: { code: 'FORBIDDEN', message: '이 방에 접근할 권한이 없습니다.' } }, + { status: 403 }, + ) + } + + return NextResponse.json>({ success: true, data: toRoomDto(room) }) + } catch { + return NextResponse.json( + { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, + { status: 500 }, + ) + } +} + +// DELETE /api/v1/rooms/:id +// 방 소프트 삭제. 생성자만 삭제 가능 +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, + { status: 401 }, + ) + } + + const { id } = await params + + try { + const room = await prisma.disputeRoom.findFirst({ + where: { id, deletedAt: null }, + }) + if (!room) { + return NextResponse.json( + { success: false, error: { code: 'ROOM_NOT_FOUND', message: '방을 찾을 수 없습니다.' } }, + { status: 404 }, + ) + } + + if (room.creatorUserId !== userId) { + return NextResponse.json( + { success: false, error: { code: 'FORBIDDEN', message: '방을 삭제할 권한이 없습니다.' } }, + { status: 403 }, + ) + } + + await prisma.disputeRoom.update({ + where: { id }, + data: { deletedAt: new Date(), roomMode: 'DELETED' }, + }) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json( + { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/v1/rooms/route.ts b/src/app/api/v1/rooms/route.ts new file mode 100644 index 0000000..f29366f --- /dev/null +++ b/src/app/api/v1/rooms/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { z } from 'zod' +import { type RoomMode as PrismaRoomMode, type CategoryGroup as PrismaCategoryGroup } from '@prisma/client' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getSessionUserId } from '@/lib/auth/session' +import { VALID_CATEGORY_GROUPS } from '@/lib/constants/dispute' +import type { ApiResponse, CategoryGroup } from '@/types/common' +import type { RoomDto, RoomListResponse, RoomMode } from '@/types/room' + +const createRoomSchema = z.object({ + categoryGroup: z.enum(VALID_CATEGORY_GROUPS, { + errorMap: () => ({ message: '카테고리는 romance, family, friend, work 중 하나여야 합니다.' }), + }), +}) + +function generateRoomNo(): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const random = Math.random().toString(36).slice(2, 8).toUpperCase() + return `R-${date}-${random}` +} + +function toRoomDto(room: { + id: string + roomNo: string + creatorUserId: string + categoryGroup: PrismaCategoryGroup + roomMode: PrismaRoomMode + inviteCreatedAt: Date | null + expiresAt: Date | null + closedAt: Date | null + createdAt: Date + updatedAt: Date +}): RoomDto { + return { + id: room.id, + roomNo: room.roomNo, + creatorUserId: room.creatorUserId, + categoryGroup: room.categoryGroup.toLowerCase() as CategoryGroup, + roomMode: room.roomMode.toLowerCase() as RoomMode, + inviteCreatedAt: room.inviteCreatedAt?.toISOString() ?? null, + expiresAt: room.expiresAt?.toISOString() ?? null, + closedAt: room.closedAt?.toISOString() ?? null, + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + } +} + +// GET /api/v1/rooms +// 내가 생성하거나 참여한 방 목록 조회 +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, + { status: 401 }, + ) + } + + const { searchParams } = new URL(request.url) + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)) + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10))) + + // ai_chat/invite_ready: creator 기준, one_to_one 이후: 참여자 기준 + const where = { + deletedAt: null, + OR: [ + { creatorUserId: userId }, + { dispute: { participants: { some: { userId } } } }, + ], + } + + try { + const [rooms, total] = await prisma.$transaction([ + prisma.disputeRoom.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: (page - 1) * limit, + }), + prisma.disputeRoom.count({ where }), + ]) + + return NextResponse.json>({ + success: true, + data: { rooms: rooms.map(toRoomDto), total, page, limit }, + }) + } catch { + return NextResponse.json( + { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, + { status: 500 }, + ) + } +} + +// POST /api/v1/rooms +// AI 대화방 생성. 기본 roomMode = ai_chat +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, + { status: 401 }, + ) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { success: false, error: { code: 'INVALID_REQUEST', message: '요청 본문을 파싱할 수 없습니다.' } }, + { status: 400 }, + ) + } + + const parsed = createRoomSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: '입력값이 올바르지 않습니다.', + fieldErrors: parsed.error.errors.map((e) => ({ + field: e.path.join('.'), + code: e.code, + message: e.message, + })), + }, + }, + { status: 400 }, + ) + } + + const { categoryGroup } = parsed.data + + try { + const room = await prisma.disputeRoom.create({ + data: { + roomNo: generateRoomNo(), + creatorUserId: userId, + categoryGroup: categoryGroup.toUpperCase() as PrismaCategoryGroup, + roomMode: 'AI_CHAT', + }, + }) + + return NextResponse.json>( + { success: true, data: toRoomDto(room) }, + { status: 201 }, + ) + } catch { + return NextResponse.json( + { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, + { status: 500 }, + ) + } +} From 2cb94ec4fdadb5b43e5dab5cb49ff3b9d90ebe98 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:22:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20NaN=20=EB=B0=A9=EC=96=B4?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/v1/rooms/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/v1/rooms/route.ts b/src/app/api/v1/rooms/route.ts index f29366f..102b609 100644 --- a/src/app/api/v1/rooms/route.ts +++ b/src/app/api/v1/rooms/route.ts @@ -60,8 +60,8 @@ export async function GET(request: NextRequest) { } const { searchParams } = new URL(request.url) - const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)) - const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10))) + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10) || 20)) // ai_chat/invite_ready: creator 기준, one_to_one 이후: 참여자 기준 const where = {