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

Check warning on line 3 in app/api/disputes/route.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

'graphqlRequest' is defined but never used
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 { getAccessToken } = await import("@/lib/auth-utils");
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 +66 to +77
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 },
);
}
}
179 changes: 176 additions & 3 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Github,
Copy,
Expand All @@ -15,6 +17,13 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
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,
Expand All @@ -24,8 +33,9 @@ import {
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";

import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { BountyFieldsFragment, DisputeReasonEnum } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
import { CompetitionSubmission } from "@/components/bounty/competition-submission";
Expand All @@ -36,6 +46,18 @@ import {
ApplicationDialog,
} from "@/components/bounty/application-dialog";
import { useBountyCTAState } from "./use-bounty-cta-state";
import { useRaiseDispute } from "@/hooks/use-bounty-application";

// Human-readable labels for each dispute reason
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",
};

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

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

export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
const router = useRouter();
const raiseDisputeMutation = useRaiseDispute();

// Dispute dialog state
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [disputeReason, setDisputeReason] = useState<DisputeReasonEnum | "">("");
const [disputeDescription, setDisputeDescription] = useState("");
const [disputeReasonError, setDisputeReasonError] = useState("");
const [disputeDescriptionError, setDisputeDescriptionError] = useState("");

const handleRaiseDispute = async () => {
// Inline validation
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: bounty.id,
reason: disputeReason as DisputeReasonEnum,
description: disputeDescription.trim(),
});
// Reset form state before closing so onOpenChange doesn't double-reset
setDisputeReason("");
setDisputeDescription("");
setDisputeReasonError("");
setDisputeDescriptionError("");
setDisputeDialogOpen(false);
toast.success("Dispute filed successfully.");
router.push(`/dispute/${result.id}`);
} catch {
toast.error("Failed to file dispute. Please try again.");
}
};

const {
walletAddress,
hasJoined,
Expand Down Expand Up @@ -251,10 +319,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 +391,111 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</>
)}

{/* Raise Dispute Dialog */}
<AlertDialog
open={disputeDialogOpen}
onOpenChange={(open) => {
if (!raiseDisputeMutation.isPending) {
setDisputeDialogOpen(open);
if (!open) {
setDisputeReason("");
setDisputeDescription("");
setDisputeReasonError("");
setDisputeDescriptionError("");
}
}
}}
>
<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">
{/* Reason */}
<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>

{/* Description */}
<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>

{/* Cancel Confirmation Dialog */}
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<AlertDialogContent>
Expand Down
46 changes: 46 additions & 0 deletions hooks/use-bounty-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { authClient } from "@/lib/auth-client";
import { bountyKeys } from "@/lib/query/query-keys";
import { MOCK_MODEL4_MILESTONES } from "@/lib/mock/model4";
import type { BountyQuery } from "@/lib/graphql/generated";
import type { DisputeReasonEnum } from "@/lib/graphql/generated";
import type { ContributorProgress, Bounty } from "@/types/bounty";
import { post } from "@/lib/api/client";

// ---------------------------------------------------------------------------
// Contract client shape (resolved from globalThis.__applicationContracts)
Expand Down Expand Up @@ -324,3 +326,47 @@ export function useApplyForSlot() {
},
});
}

// ---------------------------------------------------------------------------
// Hook: raise dispute
// ---------------------------------------------------------------------------

export interface RaiseDisputeInput {
bountyId: string;
reason: DisputeReasonEnum;
description: string;
}

export interface RaiseDisputeResult {
id: string;
campaignId: string;
reason: string;
description: string;
status: string;
createdAt: string;
}

/**
* Submits a new dispute for a bounty via the REST API.
*
* On success it returns the created dispute (including its `id`) and
* invalidates the bounty detail query so the UI reflects the new DISPUTED
* status immediately.
*/
export function useRaiseDispute() {
const qc = useQueryClient();

return useMutation<RaiseDisputeResult, Error, RaiseDisputeInput>({
mutationFn: async ({ bountyId, reason, description }) => {
return post<RaiseDisputeResult>("/api/disputes", {
campaignId: bountyId,
reason,
description,
});
},
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(variables.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}
Loading
Loading