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
1 change: 1 addition & 0 deletions api.js

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'

import { NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { sql } from '@/lib/db'
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'

export const GET = withAuth(async (_request, auth) => {
Expand All @@ -12,5 +13,18 @@ export const GET = withAuth(async (_request, auth) => {
})
if (limited) return limited

return NextResponse.json({ walletAddress: auth.walletAddress, authenticated: true }, { status: 200 })
let role: string | null = null
try {
const rows = (await sql`
SELECT role FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1
`) as { role: string }[]
role = rows[0]?.role ?? null
} catch {
role = null
}

return NextResponse.json(
{ walletAddress: auth.walletAddress, authenticated: true, role },
{ status: 200 }
)
})
83 changes: 74 additions & 9 deletions app/api/disputes/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,86 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { sql } from '@/lib/db'
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
import { activityService } from '@/lib/activity'

type RouteContext = { params: Promise<{ id: string }> }

export const POST = withRbac('dispute:resolve', async (request: NextRequest, auth: RbacContext, context: RouteContext) => {
const { id } = await context.params
try {
const { resolution, newState } = await request.json()
if (!resolution) return NextResponse.json({ error: 'Missing required field: resolution', code: 'MISSING_FIELDS' }, { status: 400 })
const [dispute] = await sql`SELECT d.*, j.escrow_contract_id FROM disputes d JOIN jobs j ON d.job_id = j.id WHERE d.id = ${id}`
if (!dispute) return NextResponse.json({ error: 'Dispute not found', code: 'DISPUTE_NOT_FOUND' }, { status: 404 })
const nextState = newState || 'resolved'
await sql`UPDATE disputes SET updated_at = CURRENT_TIMESTAMP WHERE id = ${id}`
await sql`UPDATE jobs SET escrow_status = ${nextState}, updated_at = CURRENT_TIMESTAMP WHERE id = ${dispute.job_id}`
return NextResponse.json({ message: 'Dispute resolved', disputeId: id, resolution }, { status: 200 })
const body = await request.json().catch(() => ({}))
const resolution = typeof body.resolution === 'string' ? body.resolution.trim() : ''
if (!resolution) {
return NextResponse.json(
{ error: 'Missing required field: resolution', code: 'MISSING_FIELDS' },
{ status: 400 }
)
}

const newState =
typeof body.newState === 'string' && body.newState.trim()
? body.newState.trim()
: 'resolved'

const [dispute] = await sql`
SELECT d.*, j.escrow_contract_id
FROM disputes d
JOIN jobs j ON d.job_id = j.id
WHERE d.id = ${id}
`
if (!dispute) {
return NextResponse.json(
{ error: 'Dispute not found', code: 'DISPUTE_NOT_FOUND' },
{ status: 404 }
)
}

if (dispute.status === 'resolved') {
return NextResponse.json(
{ error: 'Dispute is already resolved', code: 'ALREADY_RESOLVED' },
{ status: 409 }
)
}

const [updated] = await sql`
UPDATE disputes
SET status = 'resolved',
resolution = ${resolution},
resolved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING *
`

await sql`
UPDATE jobs
SET escrow_status = ${newState}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${dispute.job_id}
`

activityService
.log({
actorId: auth.userId,
disputeId: id,
actionType: 'dispute_resolved',
description: `Dispute resolved: ${resolution}`,
metadata: { newState, resolution },
})
.catch((err: unknown) => console.error('[activity] Failed to log dispute_resolved:', err))

return NextResponse.json(
{
message: 'Dispute resolved',
disputeId: id,
resolution,
dispute: updated,
},
{ status: 200 }
)
} catch {
return NextResponse.json({ error: 'Failed to resolve dispute', code: 'RESOLVE_FAILED' }, { status: 500 })
return NextResponse.json(
{ error: 'Failed to resolve dispute', code: 'RESOLVE_FAILED' },
{ status: 500 }
)
}
})
34 changes: 33 additions & 1 deletion app/dashboard/disputes/[id]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ describe('DisputeResolutionPage', () => {
beforeEach(() => {
vi.useRealTimers()
vi.stubGlobal('fetch', vi.fn((url: string) => {
const payload = url.endsWith('/evidence') ? mockEvidence : mockDispute
let payload: unknown = mockDispute
if (url.endsWith('/api/auth/me')) payload = { role: 'admin' }
else if (url.endsWith('/evidence')) payload = mockEvidence
return Promise.resolve({
ok: true,
status: 200,
Expand Down Expand Up @@ -63,4 +65,34 @@ describe('DisputeResolutionPage', () => {
expect(screen.getAllByText(/preview/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/download/i).length).toBeGreaterThan(0)
})

it('shows moderator resolution controls to admins', async () => {
render(<DisputeResolutionPage params={{ id: '42' }} />)

expect(await screen.findByText(/moderator resolution/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /resolve dispute/i })).toBeInTheDocument()
})

it('makes a resolved dispute read-only', async () => {
vi.stubGlobal('fetch', vi.fn((url: string) => {
let payload: unknown = {
...mockDispute,
resolution: 'Split 60/40 in favour of the freelancer.',
status: 'resolved',
}
if (url.endsWith('/api/auth/me')) payload = { role: 'admin' }
else if (url.endsWith('/evidence')) payload = mockEvidence
return Promise.resolve({
ok: true,
status: 200,
json: async () => payload,
}) as Promise<Response>
}))

render(<DisputeResolutionPage params={{ id: '42' }} />)

expect(await screen.findByText('Dispute resolved')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /submit evidence/i })).not.toBeInTheDocument()
expect(screen.queryByText(/moderator resolution/i)).not.toBeInTheDocument()
})
})
97 changes: 95 additions & 2 deletions app/dashboard/disputes/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
const [evidenceLoading, setEvidenceLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [role, setRole] = useState<string | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
const [isResolving, setIsResolving] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<PreviewFile[]>([]);
const [notes, setNotes] = useState("");
Expand Down Expand Up @@ -209,12 +213,23 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
setEvidence(Array.isArray(data.evidence) ? data.evidence : []);
}, [disputeId]);

const fetchCurrentUser = useCallback(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
setRole(typeof data?.role === "string" ? data.role : null);
} catch {
setRole(null);
}
}, []);

const refreshDispute = useCallback(
async (showLoading = false) => {
if (showLoading) setLoading(true);
setError(null);
try {
await Promise.all([fetchDispute(), fetchEvidence()]);
await Promise.all([fetchDispute(), fetchEvidence(), fetchCurrentUser()]);
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load dispute.";
setError(message);
Expand All @@ -223,7 +238,7 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
setEvidenceLoading(false);
}
},
[fetchDispute, fetchEvidence],
[fetchCurrentUser, fetchDispute, fetchEvidence],
);

useEffect(() => {
Expand Down Expand Up @@ -418,7 +433,39 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
}
};

const handleResolve = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = resolutionNotes.trim();
if (!trimmed) {
setResolveError("Enter the resolution decision before submitting.");
return;
}

setIsResolving(true);
setResolveError(null);
try {
const res = await fetch(`/api/disputes/${disputeId}/resolve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution: trimmed, newState: "resolved" }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.error || "Unable to record the resolution.");
}
setResolutionNotes("");
await refreshDispute(false);
toast.success("Dispute resolved.");
} catch (err) {
setResolveError(err instanceof Error ? err.message : "Unable to record the resolution.");
} finally {
setIsResolving(false);
}
};

const status = statusStyles[normalizeStatus(dispute?.status)] ?? statusStyles.open;
const isResolved = normalizeStatus(dispute?.status) === "resolved";

return (
<div className="p-4 sm:p-6 lg:p-8">
Expand Down Expand Up @@ -499,6 +546,19 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
</CardContent>
</Card>

{isResolved ? (
<Card className="p-0">
<CardHeader className="border-b border-border/50">
<CardTitle>Dispute resolved</CardTitle>
<CardDescription>This dispute is read-only. No further evidence can be submitted.</CardDescription>
</CardHeader>
<CardContent className="py-6">
<p className="text-sm text-muted-foreground">
{dispute?.resolution ?? "A moderator decision has been recorded."}
</p>
</CardContent>
</Card>
) : (
<form onSubmit={submitEvidence} className="space-y-6">
<Card className="p-0">
<CardHeader className="border-b border-border/50">
Expand Down Expand Up @@ -614,6 +674,7 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
</CardFooter>
</Card>
</form>
)}

<Card className="p-0">
<CardHeader className="border-b border-border/50">
Expand Down Expand Up @@ -741,6 +802,38 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) {
</div>
</CardContent>
</Card>

{role === "admin" && !isResolved && (
<Card className="p-0">
<CardHeader className="border-b border-border/50">
<CardTitle>Moderator resolution</CardTitle>
<CardDescription>
Record the final decision. This closes the dispute and makes it read-only.
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<form className="space-y-4" onSubmit={handleResolve}>
<div className="space-y-2">
<Label htmlFor="resolution-notes">Resolution decision</Label>
<Textarea
id="resolution-notes"
rows={4}
value={resolutionNotes}
onChange={(event) => setResolutionNotes(event.target.value)}
placeholder="Decision, rationale and any split amounts."
disabled={isResolving}
/>
</div>
{resolveError && (
<p className="text-sm text-destructive" role="alert">{resolveError}</p>
)}
<Button type="submit" disabled={isResolving}>
{isResolving ? "Recording..." : "Resolve dispute"}
</Button>
</form>
</CardContent>
</Card>
)}
</div>
</div>
)}
Expand Down
18 changes: 18 additions & 0 deletions branch_structure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"default_branch": "main",
"branches": [
"feat/w2-issue-255-dispute-detail-ui",
"main",
"origin"
],
"parent": {
"feat/w2-issue-255-dispute-detail-ui": "main",
"main": null,
"origin": "main"
},
"order": [
"main",
"feat/w2-issue-255-dispute-detail-ui",
"origin"
]
}
13 changes: 12 additions & 1 deletion components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Menu, X, Wallet, User, Settings, LogOut, ChevronDown, AlertTriangle } from 'lucide-react'
import { Menu, X, Wallet, User, Settings, LogOut, ChevronDown, AlertTriangle, Scale } from 'lucide-react'
import { useState } from 'react'
import { ThemeToggle } from './ui/ThemeToggle'
import {
Expand Down Expand Up @@ -174,6 +174,14 @@ export function Navbar() {
<User className="h-4 w-4" />
<span>My Profile</span>
</Link>
<Link
href="/dashboard/disputes"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
onClick={closeMenus}
>
<Scale className="h-4 w-4" />
<span>Disputes</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
Expand Down Expand Up @@ -258,6 +266,9 @@ export function Navbar() {
<Button variant="ghost" className="w-full justify-start gap-2" asChild onClick={closeMenus}>
<Link href="/dashboard/profile"><User className="h-4 w-4" /> Profile</Link>
</Button>
<Button variant="ghost" className="w-full justify-start gap-2" asChild onClick={closeMenus}>
<Link href="/dashboard/disputes"><Scale className="h-4 w-4" /> Disputes</Link>
</Button>
<Button
id="mobile-disconnect-wallet"
variant="ghost"
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"private": true,
"packageManager": "pnpm@10.32.1",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "node api.js && next dev",
"build": "node api.js && next build",
"start": "node api.js && next start",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
Loading
Loading