diff --git a/src/app/(page)/disputes/[id]/DisputePage.module.scss b/src/app/(page)/disputes/[id]/DisputePage.module.scss
index 731856a..57157f7 100644
--- a/src/app/(page)/disputes/[id]/DisputePage.module.scss
+++ b/src/app/(page)/disputes/[id]/DisputePage.module.scss
@@ -186,7 +186,6 @@
.statementCardEditable {
cursor: pointer;
- &:hover,
&:focus {
border-color: v.$color-primary-500;
outline: none;
diff --git a/src/app/(page)/disputes/[id]/page.tsx b/src/app/(page)/disputes/[id]/page.tsx
index bf9345d..df9f0e4 100644
--- a/src/app/(page)/disputes/[id]/page.tsx
+++ b/src/app/(page)/disputes/[id]/page.tsx
@@ -87,7 +87,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
'진술 내용을 정리하고 있어요',
'거의 다 됐어요, 잠시만요',
];
+ const JUDGING_MESSAGES = [
+ '작성한 진술서를 바탕으로\n분석중이에요',
+ 'AI가 양측 진술을\n검토하고 있어요',
+ '핵심 쟁점을\n파악하고 있어요',
+ '공정한 판결을\n준비하고 있어요',
+ '거의 다 됐어요,\n잠시만요',
+ ];
const [metaMsgIdx, setMetaMsgIdx] = React.useState(0);
+ const [judgingMsgIdx, setJudgingMsgIdx] = React.useState(0);
React.useEffect(() => {
if (!isExtractingMeta) return;
const timer = setInterval(() => {
@@ -96,13 +104,23 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
return () => clearInterval(timer);
}, [isExtractingMeta]);
+ React.useEffect(() => {
+ if (!isJudging) return;
+ const timer = setInterval(() => {
+ setJudgingMsgIdx((i) => (i + 1) % JUDGING_MESSAGES.length);
+ }, 2500);
+ return () => clearInterval(timer);
+ }, [isJudging]);
+
// 판결 완료/종료 상태일 때만 fetch — 불필요한 API 호출 방지
+ const isJudged = !!dispute && dispute.status === 'judged';
+
const {
data: judgment,
isLoading: judgmentLoading,
isError: judgmentError,
error: judgmentErrorData,
- } = useJudgment(id, isCompleted);
+ } = useJudgment(id, isJudged);
const showToast = useToastStore((s) => s.show);
@@ -187,7 +205,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
-
{'작성한 진술서를 바탕으로\n분석중이에요'}
+
{JUDGING_MESSAGES[judgingMsgIdx]}
);
@@ -347,10 +365,14 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
+ ) : !judgment ? (
+
) : judgmentSubTab === 'verdict' ? (
-
+
) : (
-
+
)}
>
)}
diff --git a/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss b/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss
new file mode 100644
index 0000000..c8d84a5
--- /dev/null
+++ b/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss
@@ -0,0 +1,155 @@
+@use '@/styles/abstracts/variables' as v;
+@use '@/styles/abstracts/functions' as fn;
+@use '@/styles/abstracts/mixins' as m;
+
+.page {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--bg-page);
+}
+
+.content {
+ padding: fn.r(16) fn.r(20) fn.r(100);
+ display: flex;
+ flex-direction: column;
+ gap: fn.r(16);
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: fn.r(16);
+}
+
+.label {
+ @include m.text-body-s;
+
+ color: v.$color-black;
+ margin: 0;
+}
+
+.categories {
+ display: flex;
+ flex-direction: row;
+}
+
+.categoryItem {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: fn.r(4);
+ width: fn.r(44);
+}
+
+.categoryIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: fn.r(44);
+ height: fn.r(44);
+ border-radius: fn.r(8);
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-brand);
+}
+
+.categoryLabel {
+ @include m.text-caption;
+ font-weight: v.$font-weight-bold;
+ color: var(--text-primary);
+ white-space: nowrap;
+}
+
+.statementGroup {
+ display: flex;
+ flex-direction: column;
+ gap: fn.r(8);
+}
+
+.textarea {
+ min-height: fn.r(350);
+}
+
+.footer {
+ padding: 0 fn.r(20) fn.r(40);
+ background-color: var(--bg-page);
+}
+
+.footerRow {
+ display: flex;
+ gap: fn.r(8);
+}
+
+.modalOverlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ padding: fn.r(20);
+}
+
+.modal {
+ background-color: var(--bg-page);
+ border-radius: fn.r(16);
+ padding: fn.r(24) fn.r(20);
+ width: 100%;
+ max-width: fn.r(320);
+ display: flex;
+ flex-direction: column;
+ gap: fn.r(20);
+}
+
+.modalText {
+ @include m.text-body-m;
+ color: var(--text-primary);
+ margin: 0;
+ text-align: center;
+}
+
+.modalSubText {
+ @include m.text-body-s;
+
+ color: var(--text-secondary);
+ margin: fn.r(-12) 0 0;
+ text-align: center;
+}
+
+.modalActions {
+ display: flex;
+ flex-direction: column;
+ gap: fn.r(8);
+}
+
+// ===========================
+// Saving (로딩 화면)
+// ===========================
+
+.savingScreen {
+ position: fixed;
+ inset: 0;
+ background: linear-gradient(180deg, #{v.$color-primary-200} 0%, #{v.$color-white} 50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+}
+
+.savingContent {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: fn.r(24);
+ width: fn.r(362);
+}
+
+.savingText {
+ font-family: v.$font-family-base;
+ font-size: v.$font-size-title-m;
+ font-weight: v.$font-weight-semibold;
+ color: v.$color-black;
+ text-align: center;
+ white-space: pre-line;
+ line-height: 1.5;
+}
diff --git a/src/app/(page)/rooms/[roomId]/statement/page.tsx b/src/app/(page)/rooms/[roomId]/statement/page.tsx
new file mode 100644
index 0000000..d7049f0
--- /dev/null
+++ b/src/app/(page)/rooms/[roomId]/statement/page.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import React from 'react'
+import { useRouter } from 'next/navigation'
+import { useHeaderStore } from '@/stores/headerStore'
+import { useUserMe } from '@/domains/user/hooks'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import Select from '@/components/ui/Select'
+import Textarea from '@/components/ui/Textarea'
+import { CATEGORY_ICON_MAP, CATEGORY_LABEL_MAP } from '@/components/ui/CategoryIcon'
+import type { CategoryGroup } from '@/types/common'
+import styles from './StatementPage.module.scss'
+
+const VALID_CATEGORIES: CategoryGroup[] = ['romance', 'work', 'friend', 'family']
+
+const MBTI_OPTIONS = [
+ 'INTJ', 'INTP', 'ENTJ', 'ENTP',
+ 'INFJ', 'INFP', 'ENFJ', 'ENFP',
+ 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
+ 'ISTP', 'ISFP', 'ESTP', 'ESFP',
+].map((v) => ({ value: v, label: v }))
+
+const AI_ERROR_CODES = ['AI_EXTRACTION_FAILED', 'AI_EXTRACTION_TIMEOUT', 'AI_EXTRACTION_PARSE_ERROR']
+
+export default function NewStatementPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ roomId: string }>
+ searchParams: Promise<{ category?: string }>
+}) {
+ const { roomId } = React.use(params)
+ const { category: rawCategory } = React.use(searchParams)
+ const router = useRouter()
+ const setHeader = useHeaderStore((s) => s.setHeader)
+ const { data: userMe } = useUserMe()
+
+ React.useEffect(() => {
+ setHeader({ variant: 'title', title: '사건작성', onBack: () => router.back() })
+ return () => setHeader(null)
+ }, [])
+
+ const category: CategoryGroup = VALID_CATEGORIES.includes(rawCategory as CategoryGroup)
+ ? (rawCategory as CategoryGroup)
+ : 'romance'
+
+ const [mbti, setMbti] = React.useState('')
+ const [content, setContent] = React.useState('')
+ const SAVING_MESSAGES = [
+ '나쁜 말을 검열 중입니다',
+ '내용을 저장 중입니다',
+ 'AI가 분석 중입니다',
+ '조금만 기다려주세요',
+ ]
+
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [savingMsgIdx, setSavingMsgIdx] = React.useState(0)
+ const [filterMessage, setFilterMessage] = React.useState(null)
+ const [showPersonalInfoWarning, setShowPersonalInfoWarning] = React.useState(false)
+ const [savedDisputeId, setSavedDisputeId] = React.useState(null)
+
+ React.useEffect(() => {
+ if (!userMe) return
+ setMbti(userMe.mbti ?? '')
+ }, [userMe])
+
+ React.useEffect(() => {
+ if (!isLoading) return
+ const timer = setInterval(() => {
+ setSavingMsgIdx((i) => (i + 1) % SAVING_MESSAGES.length)
+ }, 2000)
+ return () => clearInterval(timer)
+ }, [isLoading])
+
+ const handleCancel = () => {
+ fetch(`/api/rooms/${roomId}`, { method: 'DELETE', keepalive: true }).catch(() => {})
+ router.push('/')
+ }
+
+ const handleSave = async () => {
+ if (isLoading) return
+ setIsLoading(true)
+ setFilterMessage(null)
+
+ try {
+ const res = await fetch('/api/disputes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ roomId, categoryGroup: category, content }),
+ })
+ const json = await res.json() as {
+ success: boolean
+ data?: { id: string; hasPersonalInfo?: boolean }
+ error?: { code?: string; message?: string }
+ }
+
+ if (!json.success) {
+ setIsLoading(false)
+ if (json.error?.code && AI_ERROR_CODES.includes(json.error.code)) {
+ alert(json.error.message ?? 'AI 분석에 실패했습니다. 다시 시도해주세요.')
+ return
+ }
+ setFilterMessage(json.error?.message ?? '저장 중 오류가 발생했습니다. 다시 시도해주세요.')
+ return
+ }
+
+ if (json.data?.hasPersonalInfo) {
+ setSavedDisputeId(json.data.id)
+ setIsLoading(false)
+ setShowPersonalInfoWarning(true)
+ return
+ }
+
+ router.push(`/disputes/${json.data!.id}`)
+ } catch {
+ setIsLoading(false)
+ setFilterMessage('네트워크 오류가 발생했습니다. 다시 시도해주세요.')
+ }
+ }
+
+ const Icon = CATEGORY_ICON_MAP[category]
+
+ if (isLoading) {
+ return (
+
+
+
+
{SAVING_MESSAGES[savingMsgIdx]}
+
+
+ )
+ }
+
+ return (
+
+
+
+ 사건 카테고리
+
+
+
+
+
+
+ {CATEGORY_LABEL_MAP[category]}
+
+
+
+
+
+
+ 작성자님의 진술서
+
+
+
+
+
+
+
+
+
+
+
+
+ {showPersonalInfoWarning && savedDisputeId && (
+
+
+
개인정보가 포함된 것 같아요
+
+ 전화번호, 주소 등 민감한 정보가 포함되어 있을 수 있어요.
+ 진술 내용을 다시 확인해주세요.
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/api/disputes/[id]/judge/route.ts b/src/app/api/disputes/[id]/judge/route.ts
index 427abdf..faafa50 100644
--- a/src/app/api/disputes/[id]/judge/route.ts
+++ b/src/app/api/disputes/[id]/judge/route.ts
@@ -143,15 +143,17 @@ export async function POST(
const participantUserIds = dispute.participants.map((p) => p.userId)
const users = await prisma.user.findMany({
where: { id: { in: participantUserIds } },
- select: { id: true, mbti: true },
+ select: { id: true, mbti: true, nickname: true, name: true },
})
- const userMbtiMap = Object.fromEntries(users.map((u) => [u.id, u.mbti]))
+ const userMap = Object.fromEntries(users.map((u) => [u.id, u]))
const participantA = dispute.participants.find((p) => p.role === 'ROLE_A')
const participantB = dispute.participants.find((p) => p.role === 'ROLE_B')
- const mbtiA = participantA ? (userMbtiMap[participantA.userId] ?? null) : null
- const mbtiB = participantB ? (userMbtiMap[participantB.userId] ?? null) : null
+ const userA = participantA ? (userMap[participantA.userId] ?? null) : null
+ const userB = participantB ? (userMap[participantB.userId] ?? null) : null
+ const mbtiA = userA?.mbti ?? null
+ const mbtiB = userB?.mbti ?? null
- // AI 판결 생성
+ // AI 판결 생성 (당사자는 A/B로만 전달, 표시 시 user.name으로 치환)
const aiResult = await generateAiJudgment({
categoryGroup: dispute.categoryGroup,
statementA,
@@ -188,7 +190,7 @@ export async function POST(
bFault: aiResult.bFault,
aSuggestedLine: aiResult.aSuggestedLine,
bSuggestedLine: aiResult.bSuggestedLine,
- rawResponse: aiResult.mbtiNote ? JSON.stringify({ mbtiNote: aiResult.mbtiNote }) : undefined,
+ rawResponse: undefined,
resultConflictDetailId: matchedDetail.id,
resultCardId: resultCard.id,
modelName: aiResult.modelName,
diff --git a/src/app/api/disputes/route.ts b/src/app/api/disputes/route.ts
index 1560eb4..c5a5cd6 100644
--- a/src/app/api/disputes/route.ts
+++ b/src/app/api/disputes/route.ts
@@ -1,22 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
-import { type Prisma, type CategoryGroup as PrismaCategoryGroup, DisputeStatus } from '@prisma/client'
+import { Prisma, type CategoryGroup as PrismaCategoryGroup, DisputeStatus } from '@prisma/client'
import { prisma } from '@/lib/db'
import { getRequestUserId } from '@/lib/auth/session'
import { VALID_CATEGORY_GROUPS } from '@/lib/constants/dispute'
+import { moderateContent } from '@/lib/ai/moderation'
+import { extractDisputeMeta } from '@/lib/ai/judgment'
import type { ApiResponse, CategoryGroup } from '@/types/common'
import type { DisputeDto, DisputeParticipantDto, DisputeListResponse } from '@/types/dispute'
+class DuplicateDisputeError extends Error {
+ constructor() { super('DISPUTE_ALREADY_EXISTS') }
+}
+
+function withTimeout(promise: Promise, ms: number, label: string): Promise {
+ let timerId: ReturnType
+ const timeout = new Promise((_, reject) => {
+ timerId = setTimeout(() => reject(new Error(`${label} timeout`)), ms)
+ })
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timerId))
+}
+
const createDisputeSchema = z.object({
roomId: z.string().uuid('유효하지 않은 방 ID입니다.'),
categoryGroup: z.enum(VALID_CATEGORY_GROUPS, {
errorMap: () => ({ message: '카테고리는 romance, family, friend, work 중 하나여야 합니다.' }),
}),
- title: z
- .string()
- .min(1, '제목을 입력해주세요.')
- .max(200, '제목은 200자 이하로 입력해주세요.'),
- description: z.string().optional(),
+ content: z.string().min(1, '진술 내용을 입력해주세요.'),
sourceConversationId: z.string().uuid('유효하지 않은 대화 ID입니다.').optional(),
})
@@ -144,7 +154,7 @@ export async function GET(request: NextRequest) {
}
// POST /api/disputes
-// 사건 생성. 활성 방(ai_chat, invite_ready, one_to_one)에서 생성 가능. 생성자는 role_a로 확정
+// 사건 생성. content(진술)를 받아 AI 추출 → dispute 생성 → statement 저장까지 원자적 처리
export async function POST(request: NextRequest) {
const userId = await getRequestUserId(request)
if (!userId) {
@@ -183,49 +193,95 @@ export async function POST(request: NextRequest) {
)
}
- const { roomId, categoryGroup, title, description, sourceConversationId } = parsed.data
+ const { roomId, categoryGroup, content, sourceConversationId } = parsed.data
try {
- const room = await prisma.disputeRoom.findFirst({
- where: { id: roomId, creatorUserId: userId, deletedAt: null },
- })
+ // 방 유효성 확인
+ const room = await prisma.disputeRoom.findFirst({ where: { id: roomId, creatorUserId: userId, deletedAt: null } })
+
if (!room) {
return NextResponse.json(
{ success: false, error: { code: 'ROOM_NOT_FOUND', message: '방을 찾을 수 없습니다.' } },
{ status: 404 },
)
}
-
if (room.roomMode === 'CLOSED' || room.roomMode === 'EXPIRED') {
return NextResponse.json(
- {
- success: false,
- error: {
- code: 'ROOM_NOT_READY',
- message: '이미 종료되거나 만료된 방입니다.',
- details: `현재 방 상태: ${room.roomMode.toLowerCase()}`,
- },
- },
+ { success: false, error: { code: 'ROOM_NOT_READY', message: '이미 종료되거나 만료된 방입니다.' } },
{ status: 422 },
)
}
- // roomId는 DB unique 제약이 있어 소프트 삭제된 레코드도 포함해서 확인
- const existing = await prisma.dispute.findFirst({
- where: { roomId },
- })
- if (existing) {
+ // 모더레이션 + AI 추출 병렬 실행 (둘 다 타임아웃 적용)
+ const [moderationResult, metaResult] = await Promise.allSettled([
+ withTimeout(moderateContent(content), 8000, 'moderateContent'),
+ withTimeout(extractDisputeMeta(content), 10000, 'extractDisputeMeta'),
+ ])
+
+ // 모더레이션 처리 (실패 시 fail open)
+ let moderation = { isBlocked: false, hasPersonalInfo: false, confidenceScore: null as number | null, durationMs: null as number | null, modelName: null as string | null, reason: null as string | null }
+ let moderationSucceeded = false
+ if (moderationResult.status === 'fulfilled') {
+ moderation = moderationResult.value
+ moderationSucceeded = true
+ } else {
+ const reason = moderationResult.reason
+ console.error('[disputes] moderation failed', {
+ message: reason instanceof Error ? reason.message : 'unknown',
+ userId,
+ roomId,
+ })
+ }
+
+ if (moderation.isBlocked) {
+ console.info('[disputes] content blocked', {
+ userId,
+ roomId,
+ isBlocked: true,
+ confidenceScore: moderation.confidenceScore,
+ modelName: moderation.modelName,
+ reason: moderation.reason,
+ })
return NextResponse.json(
- {
- success: false,
- error: { code: 'DISPUTE_ALREADY_EXISTS', message: '이미 사건이 존재하는 방입니다.' },
- },
- { status: 409 },
+ { success: false, error: { code: 'CONTENT_BLOCKED', message: moderation.reason ?? '부적절한 표현이 포함되어 있습니다. 내용을 수정해주세요.' } },
+ { status: 422 },
)
}
- // 사건 생성 + role_a 참여자 등록 (원자적 처리)
+ // AI 추출 처리 (실패 시 에러 반환)
+ if (metaResult.status === 'rejected') {
+ const msg = metaResult.reason instanceof Error ? metaResult.reason.message : String(metaResult.reason)
+ if (msg.includes('timeout')) {
+ return NextResponse.json(
+ { success: false, error: { code: 'AI_EXTRACTION_TIMEOUT', message: 'AI 분석 시간이 초과됐습니다. 다시 시도해주세요.' } },
+ { status: 504 },
+ )
+ }
+ if (msg.includes('JSON')) {
+ return NextResponse.json(
+ { success: false, error: { code: 'AI_EXTRACTION_PARSE_ERROR', message: 'AI 응답 처리 중 오류가 발생했습니다. 다시 시도해주세요.' } },
+ { status: 502 },
+ )
+ }
+ return NextResponse.json(
+ { success: false, error: { code: 'AI_EXTRACTION_FAILED', message: 'AI 분석에 실패했습니다. 다시 시도해주세요.' } },
+ { status: 503 },
+ )
+ }
+
+ const { title, summary: description } = metaResult.value
+ if (!title) {
+ return NextResponse.json(
+ { success: false, error: { code: 'AI_EXTRACTION_FAILED', message: 'AI가 사건 제목을 추출하지 못했습니다. 다시 시도해주세요.' } },
+ { status: 503 },
+ )
+ }
+
+ // dispute + 참여자 + 진술 원자적 생성 (중복 체크 포함, serializable로 race condition 방지)
const dispute = await prisma.$transaction(async (tx) => {
+ const existing = await tx.dispute.findFirst({ where: { roomId } })
+ if (existing) throw new DuplicateDisputeError()
+
const created = await tx.dispute.create({
data: {
roomId,
@@ -233,23 +289,57 @@ export async function POST(request: NextRequest) {
title,
description,
sourceConversationId,
- status: 'DRAFT',
+ status: 'WAITING_OPPONENT',
},
})
- await tx.disputeParticipant.create({
+ const participant = await tx.disputeParticipant.create({
data: { disputeId: created.id, userId, role: 'ROLE_A' },
})
+ const statement = await tx.disputeStatement.create({
+ data: {
+ disputeId: created.id,
+ participantId: participant.id,
+ userId,
+ role: 'ROLE_A',
+ content,
+ moderationStatus: moderationSucceeded ? 'approved' : 'pending',
+ submittedAt: new Date(),
+ },
+ })
+ if (moderationSucceeded) {
+ await tx.moderationLog.create({
+ data: {
+ roomId,
+ conversationId: sourceConversationId ?? null,
+ disputeId: created.id,
+ statementId: statement.id,
+ userId,
+ target: 'STATEMENT',
+ isBlocked: false,
+ reason: null,
+ confidenceScore: moderation.confidenceScore,
+ durationMs: moderation.durationMs,
+ modelName: moderation.modelName,
+ },
+ })
+ }
return tx.dispute.findUniqueOrThrow({
where: { id: created.id },
include: { participants: { include: { user: { select: { profileImageUrl: true, image: true } } } } },
})
- })
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
- return NextResponse.json>(
- { success: true, data: toDisputeDto(dispute) },
+ return NextResponse.json>(
+ { success: true, data: { ...toDisputeDto(dispute), hasPersonalInfo: moderation.hasPersonalInfo } },
{ status: 201 },
)
- } catch {
+ } catch (error) {
+ if (error instanceof DuplicateDisputeError) {
+ return NextResponse.json(
+ { success: false, error: { code: 'DISPUTE_ALREADY_EXISTS', message: '이미 사건이 존재하는 방입니다.' } },
+ { status: 409 },
+ )
+ }
return NextResponse.json(
{ success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } },
{ status: 500 },
diff --git a/src/components/home/NewCaseButton.tsx b/src/components/home/NewCaseButton.tsx
index 2fd4daa..d789724 100644
--- a/src/components/home/NewCaseButton.tsx
+++ b/src/components/home/NewCaseButton.tsx
@@ -42,18 +42,9 @@ export default function NewCaseButton() {
const roomJson = await roomRes.json() as { success: boolean; data?: { id: string }; error?: { message?: string } }
if (!roomJson.success || !roomJson.data) throw new Error(roomJson.error?.message)
- const disputeRes = await fetch('/api/disputes', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ roomId: roomJson.data.id, categoryGroup: category, title: '새 사건' }),
- signal: controller.signal,
- })
- const disputeJson = await disputeRes.json() as { success: boolean; data?: { id: string }; error?: { message?: string } }
- if (!disputeJson.success || !disputeJson.data) throw new Error(disputeJson.error?.message)
-
- router.push(`/disputes/${disputeJson.data.id}/statement?category=${category}`)
+ router.push(`/rooms/${roomJson.data.id}/statement?category=${category}`)
} catch (err) {
- setErrorMessage(err instanceof Error && err.message ? err.message : '사건 생성에 실패했습니다. 다시 시도해주세요.')
+ setErrorMessage(err instanceof Error && err.message ? err.message : '방 생성에 실패했습니다. 다시 시도해주세요.')
setIsOpen(true)
} finally {
clearTimeout(timeout)
diff --git a/src/components/judgement/JudgmentResult.tsx b/src/components/judgement/JudgmentResult.tsx
index 8e11669..ad1cd5b 100644
--- a/src/components/judgement/JudgmentResult.tsx
+++ b/src/components/judgement/JudgmentResult.tsx
@@ -1,16 +1,19 @@
'use client';
import Image from 'next/image';
-import { useDispute } from '@/domains/dispute/dispute.hooks';
-import { useJudgment } from '@/domains/judgement/judgement.hooks';
+import type { AiJudgmentDto } from '@/types/judgment';
+import type { DisputeParticipantDto } from '@/types/dispute';
import styles from './JudgmentResult.module.scss';
interface Props {
- disputeId: string;
+ judgment: AiJudgmentDto;
+ participants: DisputeParticipantDto[];
}
function replaceRoleNames(text: string, nameA: string, nameB: string): string {
- return text.replace(/A님|B님/g, (match) => match === 'A님' ? `${nameA}님` : `${nameB}님`)
+ return text
+ .replace(/\bB\b/g, nameB)
+ .replace(/\bA\b/g, nameA)
}
function Avatar({ src }: { src: string | null }) {
@@ -25,13 +28,8 @@ function Avatar({ src }: { src: string | null }) {
);
}
-export default function JudgmentResult({ disputeId }: Props) {
- const { data: dispute } = useDispute(disputeId);
- const { data: judgment } = useJudgment(disputeId);
+export default function JudgmentResult({ judgment, participants }: Props) {
- if (!judgment || !dispute) return null;
-
- const { participants } = dispute;
const isSolo = participants.length === 1;
const participantA = participants.find((p) => p.role === 'role_a');
const participantB = participants.find((p) => p.role === 'role_b');
diff --git a/src/components/judgement/JudgmentTypeResult.tsx b/src/components/judgement/JudgmentTypeResult.tsx
index b4b3f6f..16f83d1 100644
--- a/src/components/judgement/JudgmentTypeResult.tsx
+++ b/src/components/judgement/JudgmentTypeResult.tsx
@@ -2,23 +2,20 @@
import Image from 'next/image'
import ActionPrompt from '@/components/ui/ActionPrompt'
-import { useDispute } from '@/domains/dispute/dispute.hooks'
-import { useJudgment } from '@/domains/judgement/judgement.hooks'
import { useToastStore } from '@/stores/toastStore'
+import type { AiJudgmentDto } from '@/types/judgment'
+import type { DisputeParticipantDto } from '@/types/dispute'
import styles from './JudgmentTypeResult.module.scss'
interface Props {
- disputeId: string
+ judgment: AiJudgmentDto
+ participants: DisputeParticipantDto[]
}
-export default function JudgmentTypeResult({ disputeId }: Props) {
+export default function JudgmentTypeResult({ judgment, participants }: Props) {
const showToast = useToastStore((s) => s.show)
- const { data: dispute } = useDispute(disputeId)
- const { data: judgment } = useJudgment(disputeId)
- if (!judgment || !dispute) return null
-
- const roleA = dispute.participants.find((p) => p.role === 'role_a')
+ const roleA = participants.find((p) => p.role === 'role_a')
const viewerLabel = roleA?.name ?? 'A'
const { cardImageUrl, displayName } = judgment.resultConflictDetail
diff --git a/src/lib/ai/judgment.ts b/src/lib/ai/judgment.ts
index fefa10b..0eb3f1d 100644
--- a/src/lib/ai/judgment.ts
+++ b/src/lib/ai/judgment.ts
@@ -42,7 +42,6 @@ export interface JudgmentResult {
bFault: string | null
aSuggestedLine: string | null
bSuggestedLine: string | null
- mbtiNote: string | null
conflictTypeCode: string
modelName: string
}
@@ -105,22 +104,26 @@ export async function extractDisputeMeta(statement: string): Promise getServerSession(authOptions))
+export const getCachedSession = cache(async (): Promise => {
+ const cookieStore = await cookies()
+ const token =
+ cookieStore.get('next-auth.session-token')?.value ??
+ cookieStore.get('__Secure-next-auth.session-token')?.value
+
+ if (!token) return null
+
+ const decoded = await decode({ token, secret: process.env.NEXTAUTH_SECRET! })
+ if (!decoded?.sub) return null
+
+ return {
+ user: {
+ id: decoded.sub,
+ name: decoded.name as string | null | undefined,
+ email: decoded.email as string | null | undefined,
+ image: decoded.picture as string | null | undefined,
+ },
+ expires: new Date(((decoded.exp as number | undefined) ?? 0) * 1000).toISOString(),
+ } as Session
+})