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
96 changes: 96 additions & 0 deletions app/api/disputes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccessToken } from "@/lib/auth-utils";
import { getCurrentUser } from "@/lib/server-auth";
import type { AdminDisputeDto, DisputeReasonEnum } from "@/lib/graphql/generated";

/**
* POST /api/disputes
*
* Creates a new dispute for a bounty. Forwards the request to the backend
* GraphQL API once a raiseDispute mutation is available, or to the REST
* endpoint in the interim.
*
* Body:
* campaignId – ID of the bounty being disputed
* reason – DisputeReasonEnum value
* description – Free-text explanation from the filer
*
* Returns the created AdminDisputeDto (including its `id`).
*/
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await request.json();
const { campaignId, reason, description } = body as {
campaignId?: string;
reason?: DisputeReasonEnum;
description?: string;
};

// Input validation
if (!campaignId || typeof campaignId !== "string") {
return NextResponse.json(
{ error: "campaignId is required" },
{ status: 400 },
);
}
if (!reason || typeof reason !== "string") {
return NextResponse.json(
{ error: "reason is required" },
{ status: 400 },
);
}
Comment on lines +41 to +46

Copy link
Copy Markdown

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

Reject values outside DisputeReasonEnum.

This currently accepts any string and forwards it downstream, so invalid dispute reasons bypass server-side validation and fail later with a generic backend error.

Suggested fix
-import type { AdminDisputeDto, DisputeReasonEnum } from "`@/lib/graphql/generated`";
+import { DisputeReasonEnum } from "`@/lib/graphql/generated`";
+import type { AdminDisputeDto } from "`@/lib/graphql/generated`";
...
-    if (!reason || typeof reason !== "string") {
+    if (
+      !reason ||
+      typeof reason !== "string" ||
+      !Object.values(DisputeReasonEnum).includes(reason as DisputeReasonEnum)
+    ) {
       return NextResponse.json(
-        { error: "reason is required" },
+        { error: "reason must be a valid dispute reason" },
         { status: 400 },
       );
     }
🤖 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 `@app/api/disputes/route.ts` around lines 41 - 46, Validate the incoming reason
against the DisputeReasonEnum instead of accepting any string: in the POST
handler in route.ts (where reason is checked) replace the loose typeof check
with a membership check against DisputeReasonEnum (or a helper like
isValidDisputeReason) and return NextResponse.json({ error: "invalid reason",
valid: Object.values(DisputeReasonEnum) }, { status: 400 }) when it does not
match so invalid dispute reasons are rejected early before forwarding
downstream.

if (!description || typeof description !== "string" || !description.trim()) {
return NextResponse.json(
{ error: "description is required" },
{ status: 400 },
);
}

// Forward to the backend REST API
const backendUrl = process.env.NEXT_PUBLIC_API_URL;
if (!backendUrl) {
return NextResponse.json(
{ error: "Backend API URL not configured" },
{ status: 500 },
);
}

const token = await getAccessToken();

const backendResponse = await fetch(`${backendUrl}/disputes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
campaignId,
reason,
description,
}),
});
Comment on lines +65 to +76

Copy link
Copy Markdown

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

Add a timeout to the backend proxy call.

The route waits on fetch() with no abort/timeout, so a slow or wedged backend can pin this request until the platform times it out.

Suggested fix
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 10_000);
+
-    const backendResponse = await fetch(`${backendUrl}/disputes`, {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-        ...(token ? { Authorization: `Bearer ${token}` } : {}),
-      },
-      body: JSON.stringify({
-        campaignId,
-        reason,
-        description,
-      }),
-    });
+    let backendResponse: Response;
+    try {
+      backendResponse = await fetch(`${backendUrl}/disputes`, {
+        method: "POST",
+        signal: controller.signal,
+        headers: {
+          "Content-Type": "application/json",
+          ...(token ? { Authorization: `Bearer ${token}` } : {}),
+        },
+        body: JSON.stringify({
+          campaignId,
+          reason,
+          description,
+        }),
+      });
+    } finally {
+      clearTimeout(timeout);
+    }
🤖 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 `@app/api/disputes/route.ts` around lines 66 - 77, The fetch call that posts to
`${backendUrl}/disputes` in route.ts needs an AbortController-based timeout to
avoid hanging; create an AbortController, pass controller.signal into the fetch
options for the backendResponse call, start a setTimeout (e.g. 5–10s) that calls
controller.abort(), and clear the timeout after fetch completes (use
try/finally). Ensure you handle the abort/AbortError (from fetch of
backendResponse) and return an appropriate 504/timeout response instead of
leaving the request open.


if (!backendResponse.ok) {
const errorText = await backendResponse.text();
console.error("Backend dispute creation failed:", errorText);
return NextResponse.json(
{ error: "Failed to create dispute" },
{ status: backendResponse.status },
);
}

const dispute = (await backendResponse.json()) as AdminDisputeDto;
return NextResponse.json(dispute, { status: 201 });
} catch (error) {
console.error("Error creating dispute:", error);
return NextResponse.json(
{ error: "Failed to create dispute" },
{ status: 500 },
);
}
}
19 changes: 14 additions & 5 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState } from "react";
import {
Github,
Copy,
Expand Down Expand Up @@ -32,10 +33,9 @@ import { CompetitionSubmission } from "@/components/bounty/competition-submissio
import { CompetitionStatus } from "@/components/bounty/competition-status";
import type { CancellationRecord } from "@/types/escrow";
import type { Bounty } from "@/types/bounty";
import {
ApplicationDialog,
} from "@/components/bounty/application-dialog";
import { ApplicationDialog } from "@/components/bounty/application-dialog";
import { useBountyCTAState } from "./use-bounty-cta-state";
import { RaiseDisputeDialog } from "./raise-dispute-dialog";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

Expand All @@ -45,6 +45,8 @@ interface SidebarCTAProps {
}

export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);

const {
walletAddress,
hasJoined,
Expand Down Expand Up @@ -251,10 +253,10 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
<Button
variant="ghost"
className="w-full text-gray-400 hover:text-red-400 hover:bg-red-500/5 transition-all text-xs h-8"
disabled
onClick={() => setDisputeDialogOpen(true)}
>
<Gavel className="size-3 mr-2" />
Raise a Dispute (Coming Soon)
Raise a Dispute
</Button>
</>
)}
Expand Down Expand Up @@ -323,6 +325,13 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</>
)}

{/* Raise Dispute Dialog */}
<RaiseDisputeDialog
open={disputeDialogOpen}
onOpenChange={setDisputeDialogOpen}
bountyId={bounty.id}
/>

{/* Cancel Confirmation Dialog */}
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<AlertDialogContent>
Expand Down
194 changes: 194 additions & 0 deletions components/bounty-detail/raise-dispute-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Gavel, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";

import { DisputeReasonEnum } from "@/lib/graphql/generated";
import { useRaiseDispute } from "@/hooks/use-bounty-application";

const DISPUTE_REASON_LABELS: Record<DisputeReasonEnum, string> = {
[DisputeReasonEnum.MilestoneNotDelivered]: "Milestone Not Delivered",
[DisputeReasonEnum.PoorQualityWork]: "Poor Quality Work",
[DisputeReasonEnum.DeadlineMissed]: "Deadline Missed",
[DisputeReasonEnum.ScopeChange]: "Scope Change",
[DisputeReasonEnum.MisuseOfFunds]: "Misuse of Funds",
[DisputeReasonEnum.CommunicationIssues]: "Communication Issues",
[DisputeReasonEnum.Other]: "Other",
};

interface RaiseDisputeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bountyId: string;
}

export function RaiseDisputeDialog({
open,
onOpenChange,
bountyId,
}: RaiseDisputeDialogProps) {
const router = useRouter();
const raiseDisputeMutation = useRaiseDispute();

const [disputeReason, setDisputeReason] = useState<DisputeReasonEnum | "">("");
const [disputeDescription, setDisputeDescription] = useState("");
const [disputeReasonError, setDisputeReasonError] = useState("");
const [disputeDescriptionError, setDisputeDescriptionError] = useState("");

const resetForm = () => {
setDisputeReason("");
setDisputeDescription("");
setDisputeReasonError("");
setDisputeDescriptionError("");
};

const handleRaiseDispute = async () => {
let valid = true;
if (!disputeReason) {
setDisputeReasonError("Please select a reason.");
valid = false;
} else {
setDisputeReasonError("");
}
if (!disputeDescription.trim()) {
setDisputeDescriptionError("Please describe the dispute.");
valid = false;
} else {
setDisputeDescriptionError("");
}
if (!valid) return;

try {
const result = await raiseDisputeMutation.mutateAsync({
bountyId,
reason: disputeReason as DisputeReasonEnum,
description: disputeDescription.trim(),
});
resetForm();
onOpenChange(false);
toast.success("Dispute filed successfully.");
router.push(`/dispute/${result.id}`);
} catch {
toast.error("Failed to file dispute. Please try again.");
}
};

return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!raiseDisputeMutation.isPending) {
onOpenChange(nextOpen);
if (!nextOpen) {
resetForm();
}
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-red-400">
<Gavel className="size-5" />
Raise a Dispute
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
Describe the issue with this bounty. A moderator will review your
dispute and reach out to both parties.
</AlertDialogDescription>
</AlertDialogHeader>

<div className="space-y-4 mt-2">
<div className="space-y-1.5">
<Label htmlFor="dispute-reason" className="text-sm font-medium">
Reason <span className="text-red-400">*</span>
</Label>
<Select
value={disputeReason}
onValueChange={(val) => {
setDisputeReason(val as DisputeReasonEnum);
setDisputeReasonError("");
}}
disabled={raiseDisputeMutation.isPending}
>
<SelectTrigger
id="dispute-reason"
className={disputeReasonError ? "border-red-500" : ""}
>
<SelectValue placeholder="Select a reason…" />
</SelectTrigger>
<SelectContent>
{(Object.values(DisputeReasonEnum) as DisputeReasonEnum[]).map(
(value) => (
<SelectItem key={value} value={value}>
{DISPUTE_REASON_LABELS[value]}
</SelectItem>
),
)}
</SelectContent>
</Select>
{disputeReasonError && (
<p className="text-xs text-red-400">{disputeReasonError}</p>
)}
</div>

<div className="space-y-1.5">
<Label htmlFor="dispute-description" className="text-sm font-medium">
Description <span className="text-red-400">*</span>
</Label>
<Textarea
id="dispute-description"
placeholder="Explain what happened and why you are raising this dispute…"
value={disputeDescription}
onChange={(e) => {
setDisputeDescription(e.target.value);
setDisputeDescriptionError("");
}}
className={`min-h-24 resize-none ${disputeDescriptionError ? "border-red-500" : ""}`}
disabled={raiseDisputeMutation.isPending}
/>
{disputeDescriptionError && (
<p className="text-xs text-red-400">{disputeDescriptionError}</p>
)}
</div>
</div>

<AlertDialogFooter className="mt-4">
<AlertDialogCancel disabled={raiseDisputeMutation.isPending}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => void handleRaiseDispute()}
disabled={raiseDisputeMutation.isPending}
>
{raiseDisputeMutation.isPending && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Submit Dispute
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
14 changes: 14 additions & 0 deletions lib/graphql/operations/admin-dispute.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ mutation ResolveDispute($id: ID!, $input: AdminResolveDisputeDto!) {
createdAt
}
}

# Filed by a bounty participant or creator to open a dispute on a bounty.
# Sent to the REST endpoint POST /disputes since the GraphQL schema does not
# yet expose a raiseDispute mutation. The operation is declared here for
# documentation purposes and to keep all dispute-related operations in one
# place; the actual network call is made in hooks/use-bounty-application.ts.
#
# Input fields:
# bountyId – ID of the bounty being disputed
# reason – DisputeReasonEnum value
# description – Free-text explanation from the filer
#
# On success the server returns the new AdminDisputeDto including its `id`,
# which is used to redirect the user to /dispute/{id}.
Loading