-
Notifications
You must be signed in to change notification settings - Fork 3
fix: disputes/[id] 페이지 UI 및 판결 플로우 수정 #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c1d29ac
4eee5cb
5bd2318
e4b0f65
0290619
8b857b8
3cf4426
549389c
6ccfc94
c8a2f74
b830252
cb209ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 로딩 상태를 스크린리더에 공지하도록 ARIA 속성을 추가해주세요. 현재는 시각적 텍스트만 있어 보조기기에서 저장 진행 상태를 놓칠 수 있습니다. 로딩 컨테이너(또는 텍스트)에 제안 패치- <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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!category) { | ||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||
| <div className={styles.page}> | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 메타 추출 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 |
||
| } | ||
|
|
||
| return NextResponse.json<ApiResponse<StatementData>>( | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 멱등성 구현 누락 - 이미 종료된 상태에 대한 재요청 처리 필요 코딩 가이드라인에서 "사건 종료/삭제/익명화 요청은 멱등성을 구현해야 한다"고 명시되어 있습니다. 현재 코드는 이미 🔧 멱등성을 보장하는 수정안 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
Suggested change
🤖 Prompt for AI AgentsSource: 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 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저장 오버레이 컨텐츠 폭을 반응형으로 제한해주세요.
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
🤖 Prompt for AI Agents