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
10 changes: 10 additions & 0 deletions frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { GroupActivity } from "@/components/group/group-activity"
import { GroupActions } from "@/components/group/group-actions"
import { RotationalTimelineContainer } from "@/components/group/rotational-timeline-container"
import { PoolChat } from "@/components/group/pool-chat"
import { DisputesPanel } from "@/components/disputes/disputes-panel"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import Link from "next/link"
Expand Down Expand Up @@ -136,6 +137,15 @@ export default function GroupClient({ params }: { params: Promise<{ id: string }
<GroupMembers groupId={id} contractAddress={cacheKey} />
</div>
</div>

{/* Dispute resolution — full width so cards have room for evidence links */}
<section className="mt-6">
<DisputesPanel
poolId={pool.id}
memberAddresses={(pool.pool_members ?? []).map((m) => m.member_address)}
poolAdmin={poolAdmin}
/>
</section>
</main>
</div>
)
Expand Down
90 changes: 90 additions & 0 deletions frontend/app/api/disputes/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* /api/disputes/[id]/resolve — Pool admin resolution (issue #208)
*
* POST { admin_address, outcome: "upheld" | "dismissed", resolution }
*
* Lets the pool creator close an active dispute directly — used for tie
* votes, expired windows that should still get a verdict, or urgent cases.
* Requires a written resolution note and logs to pool_activity.
*/

import { getAdminClient } from "@/lib/supabase-admin"
import { NextRequest, NextResponse } from "next/server"
import { writeLimiter } from "@/lib/rate-limit"

export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const limited = writeLimiter(req)
if (limited) return limited

const { id } = await ctx.params

let body: { admin_address?: string; outcome?: string; resolution?: string }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}

const adminAddress =
typeof body.admin_address === "string" ? body.admin_address.toLowerCase() : ""
const outcome = body.outcome
const resolution = typeof body.resolution === "string" ? body.resolution.trim() : ""

if (!id || !adminAddress) {
return NextResponse.json(
{ error: "dispute id and admin_address are required" },
{ status: 400 }
)
}
if (outcome !== "upheld" && outcome !== "dismissed") {
return NextResponse.json({ error: 'outcome must be "upheld" or "dismissed"' }, { status: 422 })
}
if (!resolution) {
return NextResponse.json({ error: "resolution note is required" }, { status: 422 })
}

const admin = getAdminClient()
const { data: dispute } = await admin.from("disputes").select("*").eq("id", id).maybeSingle()
if (!dispute) return NextResponse.json({ error: "Dispute not found" }, { status: 404 })

const { data: pool } = await admin
.from("pools")
.select("creator_address")
.eq("id", dispute.pool_id)
.maybeSingle()
if (!pool || pool.creator_address?.toLowerCase() !== adminAddress) {
return NextResponse.json({ error: "Only the pool admin can resolve disputes" }, { status: 403 })
}

if (!["open", "voting"].includes(dispute.status)) {
return NextResponse.json({ error: `Dispute is already ${dispute.status}` }, { status: 409 })
}

const resolvedStatus = outcome === "upheld" ? "resolved_upheld" : "resolved_dismissed"

const { data: updated, error: updateError } = await admin
.from("disputes")
.update({
status: resolvedStatus,
resolution,
resolved_by: adminAddress,
resolved_at: new Date().toISOString(),
})
.eq("id", id)
.select("*")
.single()

if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })

await admin.from("pool_activity").insert({
pool_id: dispute.pool_id,
activity_type: "dispute_resolved",
user_address: adminAddress,
description:
outcome === "upheld"
? `Dispute upheld by admin: ${resolution.slice(0, 140)}`
: `Dispute dismissed by admin: ${resolution.slice(0, 140)}`,
})

return NextResponse.json({ dispute: updated })
}
136 changes: 136 additions & 0 deletions frontend/app/api/disputes/[id]/vote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* /api/disputes/[id]/vote — Cast a member vote on a dispute (issue #208)
*
* POST { voter_address, vote: boolean }
*
* Rules:
* - Voter must be a pool member and cannot be the filer or the target.
* - One vote per wallet per dispute (enforced by the dispute_votes PK).
* - The dispute must still be open/voting and unexpired.
* - When one side reaches half the pool (rounded up), the dispute is
* auto-resolved and logged to pool_activity.
*/

import { getAdminClient } from "@/lib/supabase-admin"
import { NextRequest, NextResponse } from "next/server"
import { writeLimiter } from "@/lib/rate-limit"
import { canVoteOnDispute, votesNeededToResolve } from "@/lib/disputes"
import type { Database } from "@/lib/supabase"

type DisputeRow = Database["public"]["Tables"]["disputes"]["Row"]
type DisputeUpdate = Database["public"]["Tables"]["disputes"]["Update"]

const UNIQUE_VIOLATION = "23505"

export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const limited = writeLimiter(req)
if (limited) return limited

const { id } = await ctx.params

let body: { voter_address?: string; vote?: boolean }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}

const voter = typeof body.voter_address === "string" ? body.voter_address.toLowerCase() : ""
const vote = body.vote
if (!id || !voter || typeof vote !== "boolean") {
return NextResponse.json(
{ error: "dispute id, voter_address and boolean vote are required" },
{ status: 400 }
)
}

const admin = getAdminClient()
const { data: dispute } = await admin
.from("disputes")
.select("*")
.eq("id", id)
.maybeSingle<DisputeRow>()
if (!dispute) return NextResponse.json({ error: "Dispute not found" }, { status: 404 })

if (!["open", "voting"].includes(dispute.status)) {
return NextResponse.json({ error: `Dispute is already ${dispute.status}` }, { status: 409 })
}
if (new Date(dispute.expires_at).getTime() <= Date.now()) {
return NextResponse.json({ error: "Voting window has expired" }, { status: 409 })
}

// Server-side membership check (UI gating is not security).
const { data: membership } = await admin
.from("pool_members")
.select("id")
.eq("pool_id", dispute.pool_id)
.eq("member_address", voter)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 })
}

if (!canVoteOnDispute(voter, dispute, false)) {
return NextResponse.json({ error: "Filers and dispute targets cannot vote" }, { status: 403 })
}

const { error: insertError } = await admin.from("dispute_votes").insert({
dispute_id: id,
voter_address: voter,
vote,
})
if (insertError) {
if (insertError.code === UNIQUE_VIOLATION) {
return NextResponse.json({ error: "You have already voted on this dispute" }, { status: 409 })
}
return NextResponse.json({ error: insertError.message }, { status: 500 })
}

const forCount = dispute.votes_for + (vote ? 1 : 0)
const againstCount = dispute.votes_against + (vote ? 0 : 1)

// Majority threshold: half of current members, rounded up.
const { count: memberCount } = await admin
.from("pool_members")
.select("id", { count: "exact", head: true })
.eq("pool_id", dispute.pool_id)
const needed = votesNeededToResolve(memberCount ?? 0)

let resolvedStatus: DisputeRow["status"] | null = null
if (needed > 0 && forCount >= needed) resolvedStatus = "resolved_upheld"
else if (needed > 0 && againstCount >= needed) resolvedStatus = "resolved_dismissed"

const update: DisputeUpdate = {
votes_for: forCount,
votes_against: againstCount,
}
if (dispute.status === "open") update.status = "voting"
if (resolvedStatus) {
update.status = resolvedStatus
update.resolved_by = voter
update.resolved_at = new Date().toISOString()
}

const { data: updated, error: updateError } = await admin
.from("disputes")
.update(update)
.eq("id", id)
.select("*")
.single<DisputeRow>()

if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })

if (resolvedStatus) {
await admin.from("pool_activity").insert({
pool_id: dispute.pool_id,
activity_type: "dispute_resolved",
user_address: voter,
description:
resolvedStatus === "resolved_upheld"
? "Dispute upheld by community vote"
: "Dispute dismissed by community vote",
})
}

return NextResponse.json({ dispute: updated })
}
30 changes: 30 additions & 0 deletions frontend/app/api/disputes/expire/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* /api/disputes/expire — Cron job: expire stale disputes (issue #208)
*
* POST (Bearer CRON_SECRET) — flips open/voting disputes whose 72h window
* has elapsed to "expired". Scheduled like the other Vercel cron endpoints.
*/

import { getAdminClient } from "@/lib/supabase-admin"
import { NextResponse } from "next/server"

export async function POST(req: Request) {
if (!process.env.CRON_SECRET) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 })
}
const authHeader = req.headers.get("authorization")
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const { data, error } = await getAdminClient()
.from("disputes")
.update({ status: "expired" })
.in("status", ["open", "voting"])
.lt("expires_at", new Date().toISOString())
.select("id")

if (error) return NextResponse.json({ error: error.message }, { status: 500 })

return NextResponse.json({ expired: data?.length ?? 0 })
}
Loading
Loading