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
110 changes: 84 additions & 26 deletions src/app/api/disputes/[id]/statements/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { Prisma, type DisputeStatement } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getSessionUserId } from '@/lib/auth/session'
Expand Down Expand Up @@ -28,12 +29,19 @@ const statementSchema = z.object({
.optional(),
})

// 동시 제출 경쟁 조건에서 발생하는 충돌을 outer catch까지 전달하기 위한 sentinel
class StatementConflictError extends Error {
constructor() {
super('STATEMENT_ALREADY_SUBMITTED')
}
}

interface StatementData {
id: string
disputeId: string
role: string
content: string
submittedAt: null
submittedAt: string | null
hasPersonalInfo: boolean
}

Expand Down Expand Up @@ -162,20 +170,44 @@ export async function POST(
moderation = await moderateContent(content)
} catch (err) {
// Gemini 실패 시 fail open — pending 상태로 저장, ModerationLog 생략
// create/updateMany(where: submittedAt: null)로 원자적 중복 제출 방지
console.error('[moderation] Gemini call failed:', err)

const statement = await prisma.disputeStatement.upsert({
where: { disputeId_role: { disputeId, role: participant.role } },
create: {
disputeId,
participantId: participant.id,
userId,
role: participant.role,
let statement: DisputeStatement

if (isNew) {
try {
statement = await prisma.disputeStatement.create({
data: {
disputeId,
participantId: participant.id,
userId,
role: participant.role,
content,
moderationStatus: 'pending',
submittedAt: new Date(),
},
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new StatementConflictError()
}
throw e
}
} else {
const { count } = await prisma.disputeStatement.updateMany({
where: { id: existingStatement.id, submittedAt: null },
data: { content, moderationStatus: 'pending', submittedAt: new Date() },
})
if (count === 0) throw new StatementConflictError()
statement = {
...existingStatement,
content,
moderationStatus: 'pending',
},
update: { content, moderationStatus: 'pending' },
})
submittedAt: new Date(),
updatedAt: new Date(),
}
}

return NextResponse.json<ApiResponse<StatementData>>(
{
Expand All @@ -185,7 +217,7 @@ export async function POST(
disputeId: statement.disputeId,
role: statement.role.toLowerCase(),
content: statement.content,
submittedAt: null,
submittedAt: statement.submittedAt?.toISOString() ?? null,
hasPersonalInfo: false,
},
},
Expand Down Expand Up @@ -221,19 +253,37 @@ export async function POST(
}

// 정상 저장 + ModerationLog + user.mbti 업데이트 트랜잭션
// create/updateMany(where: submittedAt: null)로 원자적 중복 제출 방지
const statement = await prisma.$transaction(async (tx) => {
const stmt = await tx.disputeStatement.upsert({
where: { disputeId_role: { disputeId, role: participant.role } },
create: {
disputeId,
participantId: participant.id,
userId,
role: participant.role,
content,
moderationStatus: 'approved',
},
update: { content, moderationStatus: 'approved' },
})
let stmt: DisputeStatement

if (isNew) {
try {
stmt = await tx.disputeStatement.create({
data: {
disputeId,
participantId: participant.id,
userId,
role: participant.role,
content,
moderationStatus: 'approved',
submittedAt: new Date(),
},
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new StatementConflictError()
}
throw e
}
} else {
const { count } = await tx.disputeStatement.updateMany({
where: { id: existingStatement!.id, submittedAt: null },
data: { content, moderationStatus: 'approved', submittedAt: new Date() },
})
if (count === 0) throw new StatementConflictError()
stmt = await tx.disputeStatement.findUniqueOrThrow({ where: { id: existingStatement!.id } })
}

await tx.moderationLog.create({
data: {
Expand Down Expand Up @@ -267,13 +317,21 @@ export async function POST(
disputeId: statement.disputeId,
role: statement.role.toLowerCase(),
content: statement.content,
submittedAt: null,
submittedAt: statement.submittedAt?.toISOString() ?? null,
hasPersonalInfo: moderation.hasPersonalInfo,
},
},
{ status: isNew ? 201 : 200 },
)
} catch {
} catch (error) {
if (error instanceof StatementConflictError) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'CONFLICT', message: '이미 제출된 진술은 수정할 수 없습니다.' } },
{ status: 409 },
)
}
const message = error instanceof Error ? error.message : String(error)
console.error('[disputes/statements] api error', { message })
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } },
{ status: 500 },
Expand Down
7 changes: 6 additions & 1 deletion src/lib/ai/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ export async function moderateContent(content: string): Promise<ModerationResult
const jsonMatch = text.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('Gemini moderation returned invalid JSON')

const parsed = JSON.parse(jsonMatch[0])
let parsed: { isBlocked?: unknown; reason?: unknown; confidenceScore?: unknown; hasPersonalInfo?: unknown }
try {
parsed = JSON.parse(jsonMatch[0])
} catch {
throw new Error('Gemini moderation returned unparseable JSON')
}

return {
isBlocked: Boolean(parsed.isBlocked),
Expand Down