Skip to content
Open
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
21 changes: 21 additions & 0 deletions apps/web/app/api/v1/answers/[id]/dispute/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server"

import { ValidationError } from "@/lib/api/errors"
import { withErrorHandling } from "@/lib/api/withErrorHandling"
import { getAnswerDisputeAuditLog, getAnswerDisputesForAnswer } from "@/lib/answerDisputes"

export const GET = withErrorHandling(async (req: Request) => {
const segments = new URL(req.url).pathname.split("/").filter(Boolean)
const answerId = segments[segments.length - 3] ?? null

if (!answerId) {
throw new ValidationError("answerId is required")
}

const disputes = getAnswerDisputesForAnswer(answerId)
const auditLog = disputes.flatMap((dispute) =>
getAnswerDisputeAuditLog(dispute.id).map((entry) => ({ ...entry, disputeId: dispute.id })),
)

return NextResponse.json({ answerId, auditLog })
})
63 changes: 63 additions & 0 deletions apps/web/app/api/v1/answers/[id]/dispute/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server"

import { ValidationError } from "@/lib/api/errors"
import { withErrorHandling } from "@/lib/api/withErrorHandling"
import { createAnswerDispute, getAnswerDisputesForAnswer } from "@/lib/answerDisputes"

export const GET = withErrorHandling(async (req: Request) => {
const segments = new URL(req.url).pathname.split("/").filter(Boolean)
const answerId = segments[segments.length - 2] ?? null

if (!answerId) {
throw new ValidationError("answerId is required")
}

return NextResponse.json({ disputes: getAnswerDisputesForAnswer(answerId) })
})

export const POST = withErrorHandling(async (req: Request) => {
const segments = new URL(req.url).pathname.split("/").filter(Boolean)
const answerId = segments[segments.length - 2] ?? null

if (!answerId) {
throw new ValidationError("answerId is required")
}

let body: {
huntId?: number
clueId?: number
playerWallet?: string
submittedAnswer?: string
rejectedReason?: string
}

try {
body = await req.json()
} catch {
throw new ValidationError("Invalid request body")
}

if (!body.huntId || typeof body.huntId !== "number") {
throw new ValidationError("huntId is required", { field: "huntId" })
}
if (!body.clueId || typeof body.clueId !== "number") {
throw new ValidationError("clueId is required", { field: "clueId" })
}
if (!body.playerWallet || typeof body.playerWallet !== "string" || body.playerWallet.trim().length === 0) {
throw new ValidationError("playerWallet is required", { field: "playerWallet" })
}
if (!body.submittedAnswer || typeof body.submittedAnswer !== "string" || body.submittedAnswer.trim().length === 0) {
throw new ValidationError("submittedAnswer is required", { field: "submittedAnswer" })
}

const dispute = createAnswerDispute({
answerId,
huntId: body.huntId,
clueId: body.clueId,
playerWallet: body.playerWallet,
submittedAnswer: body.submittedAnswer.trim(),
rejectedReason: body.rejectedReason,
})

return NextResponse.json({ dispute }, { status: 201 })
})
20 changes: 20 additions & 0 deletions apps/web/app/api/v1/answers/disputes/[id]/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"

import { ValidationError } from "@/lib/api/errors"
import { withErrorHandling } from "@/lib/api/withErrorHandling"
import { getAnswerDisputeAuditLog, getAnswerDisputeById } from "@/lib/answerDisputes"

export const GET = withErrorHandling(async (req: Request) => {
const disputeId = new URL(req.url).pathname.split("/").filter(Boolean).at(-2)

if (!disputeId) {
throw new ValidationError("Dispute ID is required")
}

const dispute = getAnswerDisputeById(disputeId)
if (!dispute) {
return NextResponse.json({ auditLog: [] }, { status: 404 })
}

return NextResponse.json({ disputeId, auditLog: getAnswerDisputeAuditLog(disputeId) })
})
58 changes: 58 additions & 0 deletions apps/web/app/api/v1/answers/disputes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server"

import { ValidationError } from "@/lib/api/errors"
import { withErrorHandling } from "@/lib/api/withErrorHandling"
import { getAnswerDisputeAuditLog, getAnswerDisputeById, resolveAnswerDispute } from "@/lib/answerDisputes"

export const GET = withErrorHandling(async (req: Request) => {
const url = new URL(req.url)
const disputeId = url.pathname.split("/").filter(Boolean).at(-1)

if (!disputeId) {
throw new ValidationError("Dispute ID is required")
}

const dispute = getAnswerDisputeById(disputeId)
if (!dispute) {
return NextResponse.json({ dispute: null }, { status: 404 })
}

return NextResponse.json({ dispute })
})

export const PATCH = withErrorHandling(async (req: Request) => {
const url = new URL(req.url)
const disputeId = url.pathname.split("/").filter(Boolean).at(-1)

if (!disputeId) {
throw new ValidationError("Dispute ID is required")
}

let body: {
reviewer?: string
decision?: "approved" | "rejected" | "override" | "reviewed"
note?: string
}

try {
body = await req.json()
} catch {
throw new ValidationError("Invalid request body")
}

if (!body.reviewer || typeof body.reviewer !== "string" || body.reviewer.trim().length === 0) {
throw new ValidationError("reviewer is required", { field: "reviewer" })
}

const updated = resolveAnswerDispute(disputeId, {
reviewer: body.reviewer.trim(),
decision: body.decision ?? "reviewed",
note: body.note,
})

if (!updated) {
return NextResponse.json({ dispute: null }, { status: 404 })
}

return NextResponse.json({ dispute: updated, auditLog: getAnswerDisputeAuditLog(disputeId) })
})
62 changes: 62 additions & 0 deletions apps/web/app/api/v1/answers/disputes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server"

import { ValidationError } from "@/lib/api/errors"
import { withErrorHandling } from "@/lib/api/withErrorHandling"
import { createAnswerDispute, getAnswerDisputesForAnswer } from "@/lib/answerDisputes"

export const GET = withErrorHandling(async (req: Request) => {
const { searchParams } = new URL(req.url)
const answerId = searchParams.get("answerId")

if (!answerId) {
return NextResponse.json({ disputes: [] })
}

return NextResponse.json({ disputes: getAnswerDisputesForAnswer(answerId) })
})

export const POST = withErrorHandling(async (req: Request) => {
let body: {
answerId?: string
huntId?: number
clueId?: number
playerWallet?: string
submittedAnswer?: string
rejectedReason?: string
}

try {
body = await req.json()
} catch {
throw new ValidationError("Invalid request body")
}

const { answerId, huntId, clueId, playerWallet, submittedAnswer, rejectedReason } = body

if (!answerId || typeof answerId !== "string" || answerId.trim().length === 0) {
throw new ValidationError("answerId is required", { field: "answerId" })
}
if (!huntId || typeof huntId !== "number") {
throw new ValidationError("huntId is required", { field: "huntId" })
}
if (!clueId || typeof clueId !== "number") {
throw new ValidationError("clueId is required", { field: "clueId" })
}
if (!playerWallet || typeof playerWallet !== "string" || playerWallet.trim().length === 0) {
throw new ValidationError("playerWallet is required", { field: "playerWallet" })
}
if (!submittedAnswer || typeof submittedAnswer !== "string" || submittedAnswer.trim().length === 0) {
throw new ValidationError("submittedAnswer is required", { field: "submittedAnswer" })
}

const dispute = createAnswerDispute({
answerId,
huntId,
clueId,
playerWallet,
submittedAnswer: submittedAnswer.trim(),
rejectedReason,
})

return NextResponse.json({ dispute }, { status: 201 })
})
52 changes: 52 additions & 0 deletions apps/web/lib/__tests__/answerDisputes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "vitest"

import {
__resetAnswerDisputeStoreForTests,
createAnswerDispute,
getAnswerDisputeAuditLog,
getAnswerDisputesForAnswer,
resolveAnswerDispute,
} from "@/lib/answerDisputes"

describe("answer dispute workflow", () => {
beforeEach(() => {
__resetAnswerDisputeStoreForTests()
})

it("creates a dispute and records the initial audit entry", () => {
const dispute = createAnswerDispute({
answerId: "answer-42",
huntId: 7,
clueId: 3,
playerWallet: "GPLAYER",
submittedAnswer: "Paris",
rejectedReason: "Mismatch with expected answer",
})

expect(dispute.status).toBe("pending")
expect(dispute.auditTrail.some((entry) => entry.type === "created")).toBe(true)
expect(getAnswerDisputesForAnswer("answer-42")).toHaveLength(1)
})

it("allows a creator to override a rejected answer and records the override in the audit log", () => {
const dispute = createAnswerDispute({
answerId: "answer-99",
huntId: 9,
clueId: 4,
playerWallet: "GPLAYER",
submittedAnswer: "london",
rejectedReason: "case mismatch",
})

const resolved = resolveAnswerDispute(dispute.id, {
reviewer: "creator@example.com",
decision: "override",
note: "Accepted after review because the answer was valid",
})

expect(resolved?.status).toBe("overridden")
expect(resolved?.overrideDecision).toBe("accepted")
expect(resolved?.reviewedBy).toBe("creator@example.com")
expect(getAnswerDisputeAuditLog(dispute.id).some((entry) => entry.type === "override")).toBe(true)
})
})
Loading
Loading