Skip to content
Merged
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
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'k.kakaocdn.net',
},
{
protocol: 'https',
hostname: '*.kakaocdn.net',
},
],
},
};
Expand Down
5 changes: 2 additions & 3 deletions src/app/(page)/disputes/[id]/DisputePage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
}

.avatar {
position: relative;
width: fn.r(20);
height: fn.r(20);
border-radius: 50%;
Expand Down Expand Up @@ -240,12 +241,11 @@
align-items: center;
justify-content: space-between;
padding: fn.r(16) fn.r(20);
margin: 0 fn.r(20) fn.r(12);
background: #{v.$color-primary-100};
border-radius: fn.r(8);
border: none;
cursor: pointer;
width: calc(100% - fn.r(40));
width: 100%;
text-align: left;
}

Expand Down Expand Up @@ -289,7 +289,6 @@
right: 0;
padding: fn.r(16) fn.r(20);
background: var(--bg-page);
border-top: 1px solid var(--border-default);
}

.footerRow {
Expand Down
62 changes: 35 additions & 27 deletions src/app/(page)/disputes/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
'use client'

import React from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import AutorenewRoundedIcon from '@mui/icons-material/AutorenewRounded'
import Header from '@/components/layout/Header'
import Button from '@/components/ui/Button'
import Tabs from '@/components/ui/Tabs'
import Tab from '@/components/ui/Tab'
import Spinner from '@/components/ui/Spinner'
import StatusBadge from '@/components/ui/StatusBadge'
import CategoryIcon from '@/components/ui/CategoryIcon'
import InviteChoiceModal from '@/components/room/InviteChoiceModal'
import { useDispute, useRequestJudgment } from '@/domains/dispute/dispute.hooks'
import { useJudgment } from '@/domains/judgement/judgement.hooks'
import JudgmentResult from '@/components/judgement/JudgmentResult'
import JudgmentTypeResult from '@/components/judgement/JudgmentTypeResult'
import Tab from '@/components/ui/Tab'
import { useDispute, useRequestJudgment, useCloseDispute, disputeKeys } from '@/domains/dispute/dispute.hooks'
import { useJudgment } from '@/domains/judgement/judgement.hooks'
import { useUserMe } from '@/domains/user/hooks'
import { useToastStore } from '@/stores/toastStore'
import styles from './DisputePage.module.scss'

Expand All @@ -23,24 +26,22 @@ const TABS = [
{ id: 'statement', label: '진술' },
{ id: 'judgement', label: '판결' },
]
const CATEGORY_BG: Record<string, string> = {
romance: 'var(--category-love-bg)',
work: 'var(--category-work-bg)',
friend: 'var(--category-friend-bg)',
family: 'var(--category-family-bg)',
}

export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = React.use(params)
const router = useRouter()
const queryClient = useQueryClient()

const { data: dispute, isLoading: fetchLoading } = useDispute(id)
const { mutate: requestJudgment, isPending: isJudging } = useRequestJudgment(id)
const { mutate: closeDispute, isPending: isClosing } = useCloseDispute(id)
const { data: userMe } = useUserMe()

// judged(판결완료) + closed(종료) 모두 판결 결과 탭 노출
const isCompleted = !!dispute && (COMPLETED_STATUSES as readonly string[]).includes(dispute.status)
// 판결 완료/종료 상태일 때만 fetch — 불필요한 API 호출 방지
const { data: judgment, isLoading: judgmentLoading, isError: judgmentError } = useJudgment(id, isCompleted)

const showToast = useToastStore((s) => s.show)

const [activeTab, setActiveTab] = React.useState('statement')
Expand Down Expand Up @@ -76,13 +77,12 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
setShowSoloModal(false)
requestJudgment(undefined, {
onError: (error) => showToast(error instanceof Error ? error.message : 'AI 판결 요청에 실패했습니다.'),
onSuccess: () => router.refresh(),
})
}

if (fetchLoading) return null

if (isJudging || dispute?.status === 'judging') {
if (isJudging) {
return (
<div className={styles.judgingScreen}>
<div className={styles.judgingContent}>
Expand Down Expand Up @@ -112,15 +112,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<section className={styles.infoCard}>
<div className={styles.infoRow}>
<div className={styles.infoTitleGroup}>
<div
className={styles.categoryChip}
style={{ backgroundColor: CATEGORY_BG[dispute.categoryGroup] }}
>
<div className={styles.categoryChip}>
<CategoryIcon category={dispute.categoryGroup} />
</div>
<span className={styles.infoTitle}>{dispute.title}</span>
</div>
<AutorenewRoundedIcon sx={{ fontSize: 24, color: 'var(--icon-secondary)', flexShrink: 0 }} />
<AutorenewRoundedIcon
sx={{ fontSize: 24, color: 'var(--icon-secondary)', flexShrink: 0, cursor: 'pointer' }}
onClick={() => queryClient.invalidateQueries({ queryKey: disputeKeys.detail(id) })}
/>
</div>

{dispute.description && (
Expand All @@ -130,15 +130,16 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<div className={styles.infoMeta}>
<div className={styles.infoDateGroup}>
<div className={styles.avatarStack}>
{dispute.participants.slice(0, 2).map((p) => (
<div key={p.id} className={styles.avatar}>
{p.profileImageUrl ? (
<img src={p.profileImageUrl} alt="" className={styles.avatarImg} />
) : (
<div className={styles.avatarFallback} />
)}
</div>
))}
{dispute.participants.slice(0, 2).map((p) => {
const imgSrc = p.profileImageUrl
?? (p.userId === userMe?.id ? userMe?.profileImageUrl : null)
?? '/images/common/thumbnail-default.png'
return (
<div key={p.id} className={styles.avatar}>
<Image src={imgSrc} alt="" fill className={styles.avatarImg} />
</div>
)
})}
</div>
<span className={styles.infoDate}>{formattedDate}</span>
</div>
Expand Down Expand Up @@ -189,7 +190,6 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }

{isCompleted && activeTab === 'judgement' && (
<>
{/* 판결 / 유형 서브탭 — 공통 Tab 컴포넌트 사용 */}
<div className={styles.judgmentSubTabs}>
<Tab
items={[
Expand Down Expand Up @@ -222,7 +222,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
{!isCompleted && (
<div className={styles.footer}>
<div className={styles.footerRow}>
<Button variant="outline">사건종료</Button>
<Button
variant="outline"
disabled={isClosing}
onClick={() => closeDispute(undefined, {
onError: (error) => showToast(error instanceof Error ? error.message : '사건 종료에 실패했습니다.'),
})}
>
{isClosing ? '종료 중...' : '사건종료'}
</Button>
<Button
disabled={!canJudge}
onClick={() => isSolo ? setShowSoloModal(true) : void runJudge()}
Expand Down
29 changes: 29 additions & 0 deletions src/app/(page)/disputes/[id]/statement/StatementPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,32 @@
flex-direction: column;
gap: fn.r(8);
}

.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;
}

.savingContent {
display: flex;
flex-direction: column;
align-items: center;
gap: fn.r(20);
width: min(100%, fn.r(362));
padding: 0 fn.r(16);
}
Comment on lines +135 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

저장 오버레이 컨텐츠 폭을 반응형으로 제한해주세요.

width: fn.r(362) 고정값은 작은 화면에서 가로 스크롤/잘림을 만들 수 있습니다. max-width: 100%(또는 min(100%, fn.r(362)))를 같이 두는 편이 안전합니다.

제안 패치
 .savingContent {
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: fn.r(20);
-  width: fn.r(362);
+  width: min(100%, fn.r(362));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.savingContent {
display: flex;
flex-direction: column;
align-items: center;
gap: fn.r(20);
width: fn.r(362);
}
.savingContent {
display: flex;
flex-direction: column;
align-items: center;
gap: fn.r(20);
width: min(100%, fn.r(362));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(page)/disputes/[id]/statement/StatementPage.module.scss around
lines 135 - 141, The `.savingContent` class has a fixed width value of fn.r(362)
which can cause horizontal scrolling or content truncation on smaller screens.
Add a responsive constraint by including max-width: 100% alongside the existing
width property, or alternatively replace the width with min(100%, fn.r(362)) to
ensure the element does not exceed the available viewport width while
maintaining the intended size on larger screens.


.savingText {
font-family: v.$font-family-base;
font-size: v.$font-size-title-m;
font-weight: v.$font-weight-semibold;
line-height: v.$line-height-title-m;
color: var(--text-primary);
text-align: center;
white-space: pre-line;
}

12 changes: 12 additions & 0 deletions src/app/(page)/disputes/[id]/statement/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Header from '@/components/layout/Header'
import Button from '@/components/ui/Button'
import Select from '@/components/ui/Select'
import Spinner from '@/components/ui/Spinner'
import Textarea from '@/components/ui/Textarea'
import { CATEGORY_ICON_MAP, CATEGORY_LABEL_MAP } from '@/components/ui/CategoryIcon'
import type { CategoryGroup } from '@/types/common'
Expand Down Expand Up @@ -71,6 +72,17 @@ export default function StatementPage({
}
}

if (isLoading) {
return (
<div className={styles.savingScreen}>
<div className={styles.savingContent}>
<Spinner />
<p className={styles.savingText}>{'사건 정보를 분석하고 있어요\n잠시만 기다려주세요'}</p>
</div>
Comment on lines +77 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

로딩 상태를 스크린리더에 공지하도록 ARIA 속성을 추가해주세요.

현재는 시각적 텍스트만 있어 보조기기에서 저장 진행 상태를 놓칠 수 있습니다. 로딩 컨테이너(또는 텍스트)에 role="status"aria-live="polite"를 부여하는 것을 권장합니다.

제안 패치
-      <div className={styles.savingScreen}>
+      <div className={styles.savingScreen} role="status" aria-live="polite" aria-busy="true">
         <div className={styles.savingContent}>
           <Spinner />
           <p className={styles.savingText}>{'사건 정보를 분석하고 있어요\n잠시만 기다려주세요'}</p>
         </div>
       </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={styles.savingScreen}>
<div className={styles.savingContent}>
<Spinner />
<p className={styles.savingText}>{'사건 정보를 분석하고 있어요\n잠시만 기다려주세요'}</p>
</div>
<div className={styles.savingScreen} role="status" aria-live="polite" aria-busy="true">
<div className={styles.savingContent}>
<Spinner />
<p className={styles.savingText}>{'사건 정보를 분석하고 있어요\n잠시만 기다려주세요'}</p>
</div>
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(page)/disputes/[id]/statement/page.tsx around lines 77 - 81, The
loading screen container needs accessibility attributes to announce the loading
status to screen readers. Add role="status" and aria-live="polite" attributes to
the paragraph element with className={styles.savingText} (or alternatively to
the parent div with className={styles.savingContent}). This will ensure the
loading message is properly announced to assistive device users as a status
update rather than being visually hidden from them.

</div>
)
}

if (!category) {
return (
<div className={styles.page}>
Expand Down
23 changes: 15 additions & 8 deletions src/app/api/disputes/[id]/statements/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,21 @@ export async function POST(

// AI 2: title/description 추출 — ROLE_A 최초 저장 시에만 실행 (ROLE_B가 덮어쓰지 않도록)
if (participant.role === 'ROLE_A' && isNew) {
extractDisputeMeta(content)
.then((meta) =>
prisma.dispute.update({
where: { id: disputeId },
data: { title: meta.title, description: meta.summary },
}),
)
.catch((err) => console.error('[statements] extractDisputeMeta failed:', err))
const META_TIMEOUT_MS = 10000
try {
const meta = await Promise.race([
extractDisputeMeta(content),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('extractDisputeMeta timeout')), META_TIMEOUT_MS),
),
])
await prisma.dispute.update({
where: { id: disputeId },
data: { title: meta.title, description: meta.summary },
})
} catch (err) {
console.error('[statements] extractDisputeMeta failed:', err)
}
Comment on lines +317 to +330

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

메타 추출 await 경로에 타임아웃이 없어 요청이 장시간 블로킹될 수 있습니다.

이제 메타 추출을 동기 대기하므로, Gemini 지연/무응답 시 POST 응답이 과도하게 늦어질 수 있습니다. 타임아웃을 두고 실패 시 현재처럼 로그만 남기고 계속 반환하는 게 안전합니다.

제안 수정안
     if (participant.role === 'ROLE_A' && isNew) {
       try {
-        const meta = await extractDisputeMeta(content)
+        const meta = await Promise.race([
+          extractDisputeMeta(content),
+          new Promise<never>((_, reject) =>
+            setTimeout(() => reject(new Error('extractDisputeMeta timeout')), 3000),
+          ),
+        ])
         await prisma.dispute.update({
           where: { id: disputeId },
           data: { title: meta.title, description: meta.summary },
         })
       } catch (err) {
         console.error('[statements] extractDisputeMeta failed:', err)
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/disputes/`[id]/statements/route.ts around lines 316 - 324, The
extractDisputeMeta function call lacks a timeout mechanism, which can cause the
POST request to hang indefinitely if the Gemini API is slow or unresponsive.
Wrap the await extractDisputeMeta(content) call with a timeout wrapper (such as
Promise.race or a utility function that rejects after a specified duration like
5-10 seconds) to ensure the request doesn't block indefinitely. If the timeout
is exceeded, the existing catch block will handle it gracefully by logging the
error and allowing the response to continue without blocking.

}

return NextResponse.json<ApiResponse<StatementData>>(
Expand Down
106 changes: 106 additions & 0 deletions src/app/api/disputes/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getSessionUserId } from '@/lib/auth/session'
import type { ApiResponse } from '@/types/common'
import type { DisputeStatus } from '@/types/dispute'

const ALLOWED_STATUSES = ['closed', 'waiting_opponent', 'both_submitted'] as const

const statusSchema = z.object({
status: z.enum(ALLOWED_STATUSES, {
errorMap: () => ({ message: '허용되지 않는 상태값입니다.' }),
}),
})

// PATCH /api/disputes/:id/status
// dispute status 변경. 참여자만 가능.
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getServerSession(authOptions)
const userId = getSessionUserId(session)
if (!userId) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } },
{ status: 401 },
)
}

const { id } = await params

let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'INVALID_REQUEST', message: '요청 본문을 파싱할 수 없습니다.' } },
{ status: 400 },
)
}

const parsed = statusSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'VALIDATION_ERROR', message: parsed.error.errors[0]?.message ?? '입력값이 올바르지 않습니다.' } },
{ status: 400 },
)
}

const { status: newStatus } = parsed.data

try {
const [participant, dispute] = await Promise.all([
prisma.disputeParticipant.findFirst({ where: { disputeId: id, userId } }),
prisma.dispute.findFirst({ where: { id, deletedAt: null } }),
])

if (!dispute) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'DISPUTE_NOT_FOUND', message: '사건을 찾을 수 없습니다.' } },
{ status: 404 },
)
}

if (!participant) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'FORBIDDEN', message: '해당 사건의 참여자가 아닙니다.' } },
{ status: 403 },
)
}

// 이미 목표 상태면 멱등 성공 반환
if (dispute.status === newStatus.toUpperCase()) {
return NextResponse.json<ApiResponse<{ status: DisputeStatus }>>({
success: true,
data: { status: dispute.status.toLowerCase() as DisputeStatus },
})
}

const terminalStatuses = ['JUDGED', 'CLOSED', 'DELETED', 'EXPIRED']
if (terminalStatuses.includes(dispute.status)) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'DISPUTE_NOT_MODIFIABLE', message: '이미 종료된 사건입니다.' } },
{ status: 409 },
)
}
Comment on lines +83 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

멱등성 구현 누락 - 이미 종료된 상태에 대한 재요청 처리 필요

코딩 가이드라인에서 "사건 종료/삭제/익명화 요청은 멱등성을 구현해야 한다"고 명시되어 있습니다. 현재 코드는 이미 CLOSED 상태인 사건에 대해 409 에러를 반환하지만, 멱등성 관점에서는 동일한 상태 변경 요청(예: closed → closed)에 대해 성공 응답을 반환해야 합니다.

🔧 멱등성을 보장하는 수정안
 const terminalStatuses = ['JUDGED', 'CLOSED', 'DELETED', 'EXPIRED']
-if (terminalStatuses.includes(dispute.status)) {
+if (terminalStatuses.includes(dispute.status) && dispute.status !== newStatus.toUpperCase()) {
   return NextResponse.json<ApiResponse>(
     { success: false, error: { code: 'DISPUTE_NOT_MODIFIABLE', message: '이미 종료된 사건입니다.' } },
     { status: 409 },
   )
 }
+
+// 이미 요청한 상태면 멱등성 보장을 위해 성공 반환
+if (dispute.status === newStatus.toUpperCase()) {
+  return NextResponse.json<ApiResponse<{ status: DisputeStatus }>>({
+    success: true,
+    data: { status: dispute.status.toLowerCase() as DisputeStatus },
+  })
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const terminalStatuses = ['JUDGED', 'CLOSED', 'DELETED', 'EXPIRED']
if (terminalStatuses.includes(dispute.status)) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'DISPUTE_NOT_MODIFIABLE', message: '이미 종료된 사건입니다.' } },
{ status: 409 },
)
}
const terminalStatuses = ['JUDGED', 'CLOSED', 'DELETED', 'EXPIRED']
if (terminalStatuses.includes(dispute.status) && dispute.status !== newStatus.toUpperCase()) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'DISPUTE_NOT_MODIFIABLE', message: '이미 종료된 사건입니다.' } },
{ status: 409 },
)
}
// 이미 요청한 상태면 멱등성 보장을 위해 성공 반환
if (dispute.status === newStatus.toUpperCase()) {
return NextResponse.json<ApiResponse<{ status: DisputeStatus }>>({
success: true,
data: { status: dispute.status.toLowerCase() as DisputeStatus },
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/disputes/`[id]/status/route.ts around lines 75 - 81, The status
update endpoint does not implement idempotency for terminal dispute statuses.
Modify the terminalStatuses check in the route handler to allow requests when
the requested status matches the current dispute.status (e.g., closed to closed
should succeed, not return 409). Extract the requested status from the request
body and compare it with the current dispute.status before returning the 409
error response. Only return the 409 conflict error if the current status is
terminal AND the requested status differs from the current status, allowing
identical status change requests to succeed and maintain idempotency as required
by the coding guidelines.

Source: Coding guidelines


const updated = await prisma.dispute.update({
where: { id },
data: { status: newStatus.toUpperCase() as Uppercase<typeof newStatus> },
})

return NextResponse.json<ApiResponse<{ status: DisputeStatus }>>({
success: true,
data: { status: updated.status.toLowerCase() as DisputeStatus },
})
} catch {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } },
{ status: 500 },
)
}
}
10 changes: 10 additions & 0 deletions src/domains/dispute/dispute.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,13 @@ export async function requestJudgment(disputeId: string): Promise<void> {
const json = await parseJson(res, 'AI 판결 요청 실패')
if (!json.success) throw new Error(json.error?.message ?? 'AI 판결 요청 실패')
}

export async function closeDispute(disputeId: string): Promise<void> {
const res = await fetch(`/api/disputes/${disputeId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'closed' }),
})
const json = await parseJson(res, '사건 종료 실패')
if (!json.success) throw new Error(json.error?.message ?? '사건 종료 실패')
}
Loading