Skip to content

fix: disputes/[id] 페이지 UI 및 판결 플로우 수정 - #91

Merged
evenif99 merged 12 commits into
devfrom
fix/dispute-page-ui
Jun 20, 2026
Merged

fix: disputes/[id] 페이지 UI 및 판결 플로우 수정#91
evenif99 merged 12 commits into
devfrom
fix/dispute-page-ui

Conversation

@juahcheon

@juahcheon juahcheon commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 사건종료 API 구현: PATCH /api/disputes/[id]/status 엔드포인트 추가 및 useCloseDispute 훅 연결
  • 판결 플로우 수정: 자동 judge 호출 제거, 모든 액션은 버튼 클릭 기반으로 동작
  • 진술 저장 UX 개선: 저장 중 fullscreen 스피너 표시, extractDisputeMeta await 처리로 title/summary 추출 완료 후 이동
  • 새로고침 버튼 연결: dispute 재조회로 상대방 데이터 반영
  • 아바타 이미지: useUserMe 캐시 활용 + 기본 이미지 폴백, next/image 적용
  • AI 모델 최적화: meta 추출 및 moderation에 gemini-2.0-flash-lite 적용, 프롬프트 출력 길이 축소
  • next.config.ts: Supabase, 카카오 이미지 도메인 허용

Test plan

  • 새 사건 생성 후 진술 저장 시 스피너 표시 확인
  • 진술 저장 완료 후 disputes/[id] 진입 시 title/summary 정상 표시 확인
  • 판결받기 버튼 클릭 시에만 AI 판결 실행 확인
  • 새로고침 버튼 클릭 시 dispute 재조회 확인
  • 사건종료 버튼 동작 확인
  • 아바타 이미지 표시 확인

🤖 Generated with Claude Code

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • 분쟁 사건 종료 기능 추가
    • 진술 저장 중 로딩 화면 표시
    • Kakao CDN 원격 이미지 지원
  • 개선 사항

    • 분쟁 상세 페이지 UI 및 스타일 개선
    • 분쟁 정보 새로고침 기능 추가
    • 참여자 프로필 이미지 표시 최적화
  • 기타

    • AI 모델 업데이트

juahcheon and others added 11 commits June 20, 2026 12:40
- extractDisputeMeta 전용 모델을 gemini-2.0-flash-lite로 분리
- moderation 모델도 gemini-2.0-flash-lite로 변경
- 제목 30자→20자, 요약 100자→50자로 축소

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PATCH /api/disputes/[id]/status 엔드포인트 추가
- closeDispute API 함수 및 useCloseDispute 훅 추가
- DisputePage 사건종료 버튼에 훅 연결 및 로딩/에러 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 진술 제출 시 dispute 상태를 waiting_opponent로 업데이트
- fire-and-forget 방식으로 judge API 호출 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AbortController 30초 타임아웃 방식으로 충돌 해결 (dev 기준)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- statement 제출 후 fire-and-forget judge 호출 제거
- 스피너를 DB status 기반이 아닌 isJudging (API 호출 중) 기준으로만 표시
- 판결 완료 후 query invalidation으로 자동 UI 전환

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- extractDisputeMeta fire-and-forget → await으로 변경 (title/summary 추출 완료 후 응답)
- 저장 중 fullscreen 스피너 화면 추가
- 중복 PATCH /status 호출 제거 (statements route에서 이미 처리)
- next/image 적용 및 카카오 이미지 도메인 허용 (next.config.ts)
- avatarFallback → 기본 이미지(thumbnail-default.png) 폴백
- categoryChip inline background-color 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
talky-owl Ready Ready Preview, Comment Jun 20, 2026 8:17am

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

사건 종료(closeDispute) PATCH API 라우트, API 함수, React 훅을 신규 추가하고, DisputePage에 종료 버튼 동작·아바타·새로고침 쿼리 무효화 로직을 연결합니다. 진술 저장 중 로딩 오버레이 UI를 추가하고, AI 메타 추출 전용 모델 및 글자 제한을 조정하며 모더레이션 모델을 교체합니다.

Changes

사건 종료 기능 및 DisputePage UI 개선

Layer / File(s) Summary
사건 상태 변경 PATCH API 라우트
src/app/api/disputes/[id]/status/route.ts
인증·Zod 유효성 검사·권한·터미널 상태 체크·Prisma 업데이트·에러 응답을 포함하는 전체 라우트가 새로 추가됨.
closeDispute API 함수 및 useCloseDispute 훅
src/domains/dispute/dispute.api.ts, src/domains/dispute/dispute.hooks.ts
closeDispute 함수와 useMutation 기반 useCloseDispute 훅을 추가하고, 성공 시 disputeKeys.detail 쿼리를 무효화하도록 연결.
DisputePage 훅 연결, 렌더링 및 스타일
src/app/(page)/disputes/[id]/page.tsx, src/app/(page)/disputes/[id]/DisputePage.module.scss, next.config.ts
useCloseDispute·useUserMe·useQueryClient 연결, isCompleted 플래그로 판결 조회 조건 제어, next/image 기반 아바타 렌더링, 새로고침 쿼리 무효화 버튼, 종료 버튼 isPending 처리, CSS 스타일 조정 및 카카오 CDN 원격 패턴 추가.

진술 저장 중 로딩 오버레이 UI

Layer / File(s) Summary
진술 저장 로딩 화면
src/app/(page)/disputes/[id]/statement/page.tsx, src/app/(page)/disputes/[id]/statement/StatementPage.module.scss
isLoading 시 Spinner + 상태 텍스트를 표시하는 조기 반환 분기를 추가하고, 전체 고정 오버레이·그라데이션 배경·타이포그래피를 위한 .savingScreen/.savingContent/.savingText CSS 클래스를 신규 정의.

AI 모델 조정 및 statements 라우트 비동기 처리

Layer / File(s) Summary
AI 메타 추출 모델·길이 제한 조정 및 모더레이션 모델 교체
src/lib/ai/judgment.ts, src/lib/ai/moderation.ts
extractDisputeMeta에 전용 META_MODEL 상수를 추가하고 title/summary 글자 한도를 20자/50자로 축소하며, moderation 모델을 gemini-2.0-flash-lite로 교체.
statements 라우트 메타 추출 await 전환
src/app/api/disputes/[id]/statements/route.ts
메타 추출 호출을 then/catch 체인에서 try/await/catch 구조로 전환하여 요청 흐름에서 동기적으로 처리.

Sequence Diagram(s)

sequenceDiagram
  participant DisputePage
  participant useCloseDispute
  participant closeDispute as closeDispute()
  participant StatusRoute as PATCH /api/disputes/:id/status
  participant Prisma

  DisputePage->>useCloseDispute: 사건종료 버튼 클릭
  useCloseDispute->>closeDispute: mutationFn 호출
  closeDispute->>StatusRoute: PATCH 요청 { status: "closed" }
  StatusRoute->>Prisma: dispute + participant 조회
  Prisma-->>StatusRoute: 결과 반환
  StatusRoute->>Prisma: dispute.status 업데이트
  Prisma-->>StatusRoute: 갱신된 status
  StatusRoute-->>closeDispute: { success: true }
  closeDispute-->>useCloseDispute: void 반환
  useCloseDispute->>DisputePage: onSuccess → invalidateQueries(disputeKeys.detail)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • I5-Project/TALKY-OWL#49: statements/route.ts의 첫 진술 저장 처리 및 statement/page.tsx의 저장/로딩 UI 흐름이 직접 겹칩니다.
  • I5-Project/TALKY-OWL#70: src/lib/ai/judgment.tsextractDisputeMeta 로직(모델·프롬프트·반환 규칙)을 공통으로 수정합니다.
  • I5-Project/TALKY-OWL#90: disputes/[id]/page.tsx에서 판결 탭 동작·로딩 상태 분기(useJudgment 조건 제어 등)가 직접 겹칩니다.

Suggested reviewers

  • evenif99

Poem

🐰 토끼가 코드 밭을 깡충깡충 뛰어다니며~
사건 종료 버튼엔 로딩 불꽃이 피어나고,
아바타는 next/image 날개를 달았어요 🖼️
AI는 flash-lite로 더 가볍게 날아가고,
진술 저장엔 스피너가 빙글빙글 🌀
토끼표 코드, 오늘도 폴짝! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive 설명에 작업 내용과 테스트 계획이 포함되어 있으나, 필수 섹션 중 담당 영역, 관련 Issue, 작업 범위 확인, DB/API 변경 여부 등이 체계적으로 기재되지 않았습니다. 저장소의 PR 템플릿을 따라 담당 영역 선택, 관련 Issue/기능 요구사항 명시, 작업 범위 및 보안 확인 항목을 체계적으로 작성하시기 바랍니다.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 disputes/[id] 페이지의 UI 및 판결 플로우 수정이라는 핵심 변경사항을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dispute-page-ui

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🧹 Nitpick comments (4)
src/app/(page)/disputes/[id]/page.tsx (2)

133-142: ⚡ Quick win

이미지 로드 실패 시 fallback 처리 부재

next/imagesrc가 유효하지 않거나 로드에 실패할 경우 브라우저 기본 broken image 아이콘이 표시됩니다. onError 핸들러를 추가하여 로드 실패 시 기본 이미지로 fallback 처리하는 것이 좋습니다.

♻️ 이미지 로드 실패 처리 추가
+const [imageErrors, setImageErrors] = React.useState<Set<string>>(new Set())
+
 {dispute.participants.slice(0, 2).map((p) => {
-  const imgSrc = p.profileImageUrl
+  const imgSrc = imageErrors.has(p.id)
+    ? '/images/common/thumbnail-default.png'
+    : 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} />
+      <Image 
+        src={imgSrc} 
+        alt="" 
+        fill 
+        className={styles.avatarImg}
+        onError={() => setImageErrors(prev => new Set(prev).add(p.id))}
+      />
     </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]/page.tsx around lines 133 - 142, The Image
component rendering participant avatars in the dispute.participants.slice(0,
2).map function lacks error handling for image load failures, which will display
a broken image icon if the image URL is invalid or fails to load. Add an onError
handler to the Image component that sets the source to the fallback default
image path '/images/common/thumbnail-default.png' when the initial image fails
to load, ensuring graceful fallback behavior.

115-123: ⚡ Quick win

새로고침 버튼이 쿼리 무효화만 수행하고 UI 업데이트를 보장하지 않음

queryClient.invalidateQueries는 쿼리를 stale로 표시하지만, 컴포넌트가 마운트된 상태에서 자동으로 refetch되지 않을 수 있습니다. 사용자가 새로고침 버튼을 클릭했을 때 즉시 데이터를 다시 가져오려면 refetch 또는 invalidateQueries와 함께 refetch: true 옵션을 사용하는 것이 좋습니다.

♻️ 즉시 refetch를 보장하는 수정안
 <AutorenewRoundedIcon
   sx={{ fontSize: 24, color: 'var(--icon-secondary)', flexShrink: 0, cursor: 'pointer' }}
-  onClick={() => queryClient.invalidateQueries({ queryKey: disputeKeys.detail(id) })}
+  onClick={() => queryClient.invalidateQueries({ queryKey: disputeKeys.detail(id), refetchType: 'active' })}
 />

또는 useDispute 훅의 refetch를 직접 호출:

-const { data: dispute, isLoading: fetchLoading } = useDispute(id)
+const { data: dispute, isLoading: fetchLoading, refetch } = useDispute(id)
 
 // ...
 
 <AutorenewRoundedIcon
   sx={{ fontSize: 24, color: 'var(--icon-secondary)', flexShrink: 0, cursor: 'pointer' }}
-  onClick={() => queryClient.invalidateQueries({ queryKey: disputeKeys.detail(id) })}
+  onClick={() => refetch()}
 />
🤖 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]/page.tsx around lines 115 - 123, The onClick
handler for the AutorenewRoundedIcon refresh button only invalidates the query
but does not guarantee an immediate refetch of the data. Update the onClick
handler to include the refetch: true option in the invalidateQueries call to
ensure the data is immediately refetched when the user clicks the refresh
button, or alternatively call the refetch function directly from the useDispute
hook if available. This ensures the UI reflects fresh data immediately after the
refresh action.
next.config.ts (1)

19-22: 💤 Low value

*중복 패턴 - .kakaocdn.net이 k.kakaocdn.net을 포함함

Line 17의 k.kakaocdn.net 패턴은 Line 21의 *.kakaocdn.net 와일드카드 패턴에 포함됩니다. 두 패턴을 모두 유지해도 동작에는 문제가 없지만, 불필요한 중복을 제거하여 설정을 단순화할 수 있습니다.

♻️ 중복 제거 수정안
 images: {
   remotePatterns: [
     {
       protocol: 'https',
       hostname: '*.supabase.co',
       pathname: '/storage/v1/object/public/**',
     },
-    {
-      protocol: 'https',
-      hostname: 'k.kakaocdn.net',
-    },
     {
       protocol: 'https',
       hostname: '*.kakaocdn.net',
     },
   ],
 },
🤖 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 `@next.config.ts` around lines 19 - 22, The specific hostname pattern
k.kakaocdn.net is redundant because it is already matched by the wildcard
pattern *.kakaocdn.net shown in the diff. Remove the specific k.kakaocdn.net
configuration object (the one appearing before the wildcard pattern) to
eliminate the duplication and simplify the configuration, as the wildcard
pattern will cover all subdomains of kakaocdn.net including k.kakaocdn.net.
src/app/api/disputes/[id]/status/route.ts (1)

92-97: ⚡ Quick win

catch 블록의 에러 처리가 너무 일반적임

모든 예외를 500 Internal Server Error로 처리하면 Prisma 제약 조건 위반이나 특정 데이터베이스 에러의 컨텍스트가 손실됩니다. 클라이언트가 에러 원인을 파악하기 어려워질 수 있습니다.

♻️ 구체적인 에러 타입 처리 예시
 } catch (error) {
+  // Prisma 관련 에러는 더 구체적으로 처리 가능
+  if (error instanceof Error && error.message.includes('Record to update not found')) {
+    return NextResponse.json<ApiResponse>(
+      { success: false, error: { code: 'DISPUTE_NOT_FOUND', message: '사건을 찾을 수 없습니다.' } },
+      { status: 404 },
+    )
+  }
   return NextResponse.json<ApiResponse>(
     { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } },
     { status: 500 },
   )
 }
🤖 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 92 - 97, The catch
block at the end of the dispute status update route is handling all exceptions
generically and returning a 500 error without distinguishing between different
error types. To fix this, replace the generic catch block with specific error
type handling that checks for Prisma errors (such as constraint violations using
PrismaClientKnownRequestError), validation errors, and other specific database
exceptions, then return appropriate HTTP status codes and error messages for
each error type while keeping a fallback handler for unexpected errors. This
will allow the client to understand whether the error is due to invalid data,
constraint violations, or actual server errors.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/app/`(page)/disputes/[id]/statement/page.tsx:
- Around line 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.

In `@src/app/`(page)/disputes/[id]/statement/StatementPage.module.scss:
- Around line 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.

In `@src/app/api/disputes/`[id]/statements/route.ts:
- Around line 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.

In `@src/app/api/disputes/`[id]/status/route.ts:
- Around line 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.

In `@src/lib/ai/judgment.ts`:
- Around line 94-96: The title and summary fields are being truncated using
String.prototype.slice, which operates on UTF-16 code units and can break
Unicode characters like emojis with surrogate pairs. Replace the slice
operations on parsed.title and parsed.summary with an Array.from based approach
that converts the string to an array of properly handled Unicode characters,
then slices at the desired character count and rejoins the result. This will
prevent surrogate pairs and basic emojis from being split mid-character.

---

Nitpick comments:
In `@next.config.ts`:
- Around line 19-22: The specific hostname pattern k.kakaocdn.net is redundant
because it is already matched by the wildcard pattern *.kakaocdn.net shown in
the diff. Remove the specific k.kakaocdn.net configuration object (the one
appearing before the wildcard pattern) to eliminate the duplication and simplify
the configuration, as the wildcard pattern will cover all subdomains of
kakaocdn.net including k.kakaocdn.net.

In `@src/app/`(page)/disputes/[id]/page.tsx:
- Around line 133-142: The Image component rendering participant avatars in the
dispute.participants.slice(0, 2).map function lacks error handling for image
load failures, which will display a broken image icon if the image URL is
invalid or fails to load. Add an onError handler to the Image component that
sets the source to the fallback default image path
'/images/common/thumbnail-default.png' when the initial image fails to load,
ensuring graceful fallback behavior.
- Around line 115-123: The onClick handler for the AutorenewRoundedIcon refresh
button only invalidates the query but does not guarantee an immediate refetch of
the data. Update the onClick handler to include the refetch: true option in the
invalidateQueries call to ensure the data is immediately refetched when the user
clicks the refresh button, or alternatively call the refetch function directly
from the useDispute hook if available. This ensures the UI reflects fresh data
immediately after the refresh action.

In `@src/app/api/disputes/`[id]/status/route.ts:
- Around line 92-97: The catch block at the end of the dispute status update
route is handling all exceptions generically and returning a 500 error without
distinguishing between different error types. To fix this, replace the generic
catch block with specific error type handling that checks for Prisma errors
(such as constraint violations using PrismaClientKnownRequestError), validation
errors, and other specific database exceptions, then return appropriate HTTP
status codes and error messages for each error type while keeping a fallback
handler for unexpected errors. This will allow the client to understand whether
the error is due to invalid data, constraint violations, or actual server
errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a978f124-7543-4458-8590-20e75a7191d2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c80b15 and b830252.

📒 Files selected for processing (11)
  • next.config.ts
  • src/app/(page)/disputes/[id]/DisputePage.module.scss
  • src/app/(page)/disputes/[id]/page.tsx
  • src/app/(page)/disputes/[id]/statement/StatementPage.module.scss
  • src/app/(page)/disputes/[id]/statement/page.tsx
  • src/app/api/disputes/[id]/statements/route.ts
  • src/app/api/disputes/[id]/status/route.ts
  • src/domains/dispute/dispute.api.ts
  • src/domains/dispute/dispute.hooks.ts
  • src/lib/ai/judgment.ts
  • src/lib/ai/moderation.ts

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

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.

Comment on lines +135 to +141
.savingContent {
display: flex;
flex-direction: column;
align-items: center;
gap: fn.r(20);
width: fn.r(362);
}

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.

Comment on lines +316 to +324
try {
const meta = await extractDisputeMeta(content)
await prisma.dispute.update({
where: { id: disputeId },
data: { title: meta.title, description: meta.summary },
})
} catch (err) {
console.error('[statements] extractDisputeMeta failed:', err)
}

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.

Comment on lines +75 to +81
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 },
)
}

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

Comment thread src/lib/ai/judgment.ts Outdated
Comment on lines +94 to +96
title: parsed.title.slice(0, 20),
summary: parsed.summary.slice(0, 50),
modelName: META_MODEL,

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

🧩 Analysis chain

🌐 Web query:

JavaScript String.prototype.slice가 surrogate pair(이모지)를 중간에서 자를 수 있는지, 그리고 Array.from 기반 문자 단위 절단이 안전한지 확인해줘.

💡 Result:

JavaScript의 String.prototype.slice 메서드는 UTF-16 코드 유닛(code unit) 단위로 동작하므로, surrogate pair(이모지 등)의 중간을 잘라 잘못된(broken) 문자열을 생성할 수 있습니다 [1][2][3]. 따라서 이모지 처리가 필요한 경우 slice를 직접 사용하는 것은 권장되지 않습니다 [2][4][3]. Array.from이나 스프레드 연산자([...str])를 사용하여 문자열을 배열로 변환하는 방식은 String 객체의 내장 이터레이터([Symbol.iterator])를 활용하며, 이는 코드 유닛이 아닌 유니코드 코드 포인트(code point) 단위로 동작합니다 [5][6][7]. 이 방식은 단순 surrogate pair를 안전하게 유지해주므로 slice보다 훨씬 안전합니다 [1][4][8]. 하지만 주의할 점은 '안전하다'는 기준이 '코드 포인트' 단위라는 점입니다 [7][9]. 다수의 유니코드 문자로 구성된 복합 이모지(예: ZWJ 시퀀스, 피부색 수정자, 결합형 이모지)는 여전히 여러 코드 포인트로 나뉠 수 있습니다 [5][6][7]. 요약하자면: 1. String.prototype.slice: 코드 유닛 단위 절단이 발생하여 surrogate pair가 깨질 위험이 큼 [1][2][3]. 2. Array.from: 코드 포인트 단위로 분할하여 surrogate pair는 보호하지만, 복합 이모지(grapheme clusters)는 분리될 수 있음 [5][6][7]. 3. 권장 사항: 복합 이모지를 포함한 실제 '사용자가 인식하는 문자(grapheme clusters)' 단위로 정확하게 자르고 싶다면 ES2023에 도입된 Intl.Segmenter API를 사용하는 것이 가장 정확하고 안전합니다 [10].

Citations:


🏁 Script executed:

fd -t f judgment.ts

Repository: I5-Project/TALKY-OWL

Length of output: 109


🏁 Script executed:

wc -l src/lib/ai/judgment.ts

Repository: I5-Project/TALKY-OWL

Length of output: 91


🏁 Script executed:

cat -n src/lib/ai/judgment.ts | head -110 | tail -30

Repository: I5-Project/TALKY-OWL

Length of output: 1091


UTF-16 코드 유닛 기반 절단으로 일부 유니코드 문자가 깨질 수 있습니다.

String.prototype.slice는 UTF-16 코드 유닛 단위로 동작하므로, 이모지 같은 서로게이트 쌍(surrogate pair)을 중간에서 잘라 손상된 문자열을 생성할 수 있습니다.

제안된 Array.from 기반 접근은 대부분의 경우 개선이 되어 기본 이모지와 서로게이트 쌍을 보호합니다. 다만 ZWJ 시퀀스나 피부색 수정자 같은 복합 이모지는 여전히 분리될 수 있으므로, 더 정확한 처리가 필요하면 Intl.Segmenter 사용을 고려하세요.

제안 수정안
+function sliceByChars(value: string, maxChars: number): string {
+  return Array.from(value).slice(0, maxChars).join('')
+}
+
   return {
-    title: parsed.title.slice(0, 20),
-    summary: parsed.summary.slice(0, 50),
+    title: sliceByChars(parsed.title, 20),
+    summary: sliceByChars(parsed.summary, 50),
     modelName: META_MODEL,
   }
📝 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
title: parsed.title.slice(0, 20),
summary: parsed.summary.slice(0, 50),
modelName: META_MODEL,
function sliceByChars(value: string, maxChars: number): string {
return Array.from(value).slice(0, maxChars).join('')
}
return {
title: sliceByChars(parsed.title, 20),
summary: sliceByChars(parsed.summary, 50),
modelName: META_MODEL,
}
🤖 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/lib/ai/judgment.ts` around lines 94 - 96, The title and summary fields
are being truncated using String.prototype.slice, which operates on UTF-16 code
units and can break Unicode characters like emojis with surrogate pairs. Replace
the slice operations on parsed.title and parsed.summary with an Array.from based
approach that converts the string to an array of properly handled Unicode
characters, then slices at the desired character count and rejoins the result.
This will prevent surrogate pairs and basic emojis from being split
mid-character.

- StatementPage: savingContent 폭 반응형 처리 (min(100%, 362px))
- statements/route: extractDisputeMeta에 10초 타임아웃 추가
- status/route: 동일 상태 재요청 시 멱등 성공 반환
- judgment.ts: Array.from 기반 유니코드 안전 문자열 절단

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@juahcheon

Copy link
Copy Markdown
Collaborator Author

@evenif99 머지 부탁드립니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants