diff --git a/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx b/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx index ca9a2a7..a33912e 100644 --- a/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx +++ b/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx @@ -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" @@ -136,6 +137,15 @@ export default function GroupClient({ params }: { params: Promise<{ id: string } + + {/* Dispute resolution — full width so cards have room for evidence links */} +
+ m.member_address)} + poolAdmin={poolAdmin} + /> +
) diff --git a/frontend/app/api/disputes/[id]/resolve/route.ts b/frontend/app/api/disputes/[id]/resolve/route.ts new file mode 100644 index 0000000..4dc44c5 --- /dev/null +++ b/frontend/app/api/disputes/[id]/resolve/route.ts @@ -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 }) +} diff --git a/frontend/app/api/disputes/[id]/vote/route.ts b/frontend/app/api/disputes/[id]/vote/route.ts new file mode 100644 index 0000000..8d0c90d --- /dev/null +++ b/frontend/app/api/disputes/[id]/vote/route.ts @@ -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() + 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() + + 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 }) +} diff --git a/frontend/app/api/disputes/expire/route.ts b/frontend/app/api/disputes/expire/route.ts new file mode 100644 index 0000000..191d97b --- /dev/null +++ b/frontend/app/api/disputes/expire/route.ts @@ -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 }) +} diff --git a/frontend/app/api/disputes/route.ts b/frontend/app/api/disputes/route.ts new file mode 100644 index 0000000..ccb2879 --- /dev/null +++ b/frontend/app/api/disputes/route.ts @@ -0,0 +1,207 @@ +/** + * /api/disputes — Pool dispute resolution API (issue #208) + * + * GET /api/disputes?pool_id=[&status=open,voting] + * Lists disputes for a pool, newest first. + * + * POST /api/disputes + * { pool_id, filer_address, dispute_type, description, + * target_address?, evidence_urls? } + * Files a new dispute. Members only; one active dispute per filer per pool; + * auto-expires after DISPUTE_EXPIRY_HOURS. + */ + +import { getAdminClient } from "@/lib/supabase-admin" +import { NextRequest, NextResponse } from "next/server" +import { readLimiter, writeLimiter } from "@/lib/rate-limit" +import { + DISPUTE_DESCRIPTION_MAX_LENGTH, + DISPUTE_EXPIRY_HOURS, + DISPUTE_MAX_EVIDENCE_URLS, + isDisputeType, + validateEvidenceUrls, +} from "@/lib/disputes" +import type { DisputeStatus } from "@/lib/disputes" + +const ACTIVE_STATUSES = ["open", "voting"] as const + +interface DisputeRow { + id: string + pool_id: string + filer_address: string + target_address: string | null + dispute_type: string + description: string + evidence_urls: string[] + status: string + resolution: string | null + votes_for: number + votes_against: number + resolved_by: string | null + resolved_at: string | null + created_at: string + expires_at: string +} + +async function isMember(poolId: string, wallet: string): Promise { + const { data } = await getAdminClient() + .from("pool_members") + .select("id") + .eq("pool_id", poolId) + .eq("member_address", wallet.toLowerCase()) + .maybeSingle() + return data !== null +} + +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const poolId = req.nextUrl.searchParams.get("pool_id") + const statusParam = req.nextUrl.searchParams.get("status") + if (!poolId) return NextResponse.json({ error: "pool_id required" }, { status: 400 }) + + let query = getAdminClient() + .from("disputes") + .select("*") + .eq("pool_id", poolId) + .order("created_at", { ascending: false }) + .limit(100) + + if (statusParam) { + const VALID_STATUSES: readonly DisputeStatus[] = [ + "open", + "voting", + "resolved_upheld", + "resolved_dismissed", + "expired", + ] + const statuses = statusParam + .split(",") + .map((s) => s.trim()) + .filter((s): s is DisputeStatus => (VALID_STATUSES as readonly string[]).includes(s)) + if (statuses.length > 0) query = query.in("status", statuses) + } + + const { data, error } = await query + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ disputes: data ?? [] }) +} + +// ── POST ────────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + const limited = writeLimiter(req) + if (limited) return limited + + let body: Record + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const poolId = typeof body.pool_id === "string" ? body.pool_id : "" + const filer = typeof body.filer_address === "string" ? body.filer_address.toLowerCase() : "" + const target = + typeof body.target_address === "string" && body.target_address.trim().length > 0 + ? body.target_address.trim().toLowerCase() + : null + const disputeType = body.dispute_type + const description = typeof body.description === "string" ? body.description.trim() : "" + + if (!poolId || !filer) { + return NextResponse.json({ error: "pool_id and filer_address are required" }, { status: 400 }) + } + if (!isDisputeType(disputeType)) { + return NextResponse.json( + { + error: + "dispute_type must be one of: missed_deposit, unfair_penalty, admin_abuse, member_misconduct, other", + }, + { status: 422 } + ) + } + if (!description || description.length > DISPUTE_DESCRIPTION_MAX_LENGTH) { + return NextResponse.json( + { error: `description is required (max ${DISPUTE_DESCRIPTION_MAX_LENGTH} characters)` }, + { status: 422 } + ) + } + + const evidence = validateEvidenceUrls(body.evidence_urls) + if (evidence.invalid.length > 0) { + return NextResponse.json( + { error: "evidence_urls must be valid http(s) URLs", invalid: evidence.invalid }, + { status: 422 } + ) + } + + // Filer must be a pool member; an identified target must be too. + const filerIsMember = await isMember(poolId, filer) + if (!filerIsMember) { + return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 }) + } + if (target && target !== filer) { + const admin = getAdminClient() + const [targetIsMember, { data: pool }] = await Promise.all([ + isMember(poolId, target), + admin.from("pools").select("creator_address").eq("id", poolId).maybeSingle(), + ]) + const isAdmin = pool?.creator_address?.toLowerCase() === target + if (!targetIsMember && !isAdmin) { + return NextResponse.json( + { error: "Target address is not a pool participant" }, + { status: 422 } + ) + } + } + + // One active dispute per filer per pool keeps the queue focused. + const { data: existing } = await getAdminClient() + .from("disputes") + .select("id") + .eq("pool_id", poolId) + .eq("filer_address", filer) + .in("status", ACTIVE_STATUSES) + .maybeSingle() + if (existing) { + return NextResponse.json( + { error: "You already have an active dispute in this pool" }, + { status: 409 } + ) + } + + const expiresAt = new Date(Date.now() + DISPUTE_EXPIRY_HOURS * 60 * 60 * 1000).toISOString() + + const { data, error } = await getAdminClient() + .from("disputes") + .insert({ + pool_id: poolId, + filer_address: filer, + target_address: target, + dispute_type: disputeType, + description, + evidence_urls: evidence.valid.slice(0, DISPUTE_MAX_EVIDENCE_URLS), + status: "open", + expires_at: expiresAt, + }) + .select("*") + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + await getAdminClient() + .from("pool_activity") + .insert({ + pool_id: poolId, + activity_type: "dispute_filed", + user_address: filer, + description: `Dispute filed (${disputeType})`, + }) + + return NextResponse.json({ dispute: data }, { status: 201 }) +} diff --git a/frontend/components/disputes/dispute-card.tsx b/frontend/components/disputes/dispute-card.tsx new file mode 100644 index 0000000..1f8debe --- /dev/null +++ b/frontend/components/disputes/dispute-card.tsx @@ -0,0 +1,197 @@ +"use client" + +/** + * DisputeCard — single dispute row for the pool disputes panel (issue #208) + * Shows status badge, counts, description, evidence links, and vote buttons + * for eligible voters. Resolution notes appear once resolved. + */ + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { AlertTriangle, ExternalLink, Gavel, Scale } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import type { DisputeRecord } from "@/lib/disputes" + +const STATUS_STYLES: Record = { + open: "bg-blue-500/15 text-blue-600 dark:text-blue-400", + voting: "bg-blue-500/15 text-blue-600 dark:text-blue-400", + resolved_upheld: "bg-green-500/15 text-green-600 dark:text-green-400", + resolved_dismissed: "bg-gray-500/15 text-gray-600 dark:text-gray-400", + expired: "bg-orange-500/15 text-orange-600 dark:text-orange-400", +} + +interface DisputeCardProps { + dispute: DisputeRecord + viewerAddress: string | null + isAdmin: boolean + totalMembers: number + onVote: (disputeId: string, inFavor: boolean) => void + onResolve: (disputeId: string, outcome: "upheld" | "dismissed", resolution: string) => void +} + +export function DisputeCard({ + dispute, + viewerAddress, + isAdmin, + totalMembers, + onVote, + onResolve, +}: DisputeCardProps) { + const t = useTranslations("group.disputes") + const [resolveOpen, setResolveOpen] = useState(false) + const [resolutionDraft, setResolutionDraft] = useState("") + + const viewer = viewerAddress?.toLowerCase() ?? null + const isActive = dispute.status === "open" || dispute.status === "voting" + const isExpired = new Date(dispute.expires_at).getTime() <= Date.now() + + const isFiler = viewer === dispute.filer_address.toLowerCase() + const isTarget = !!dispute.target_address && viewer === dispute.target_address.toLowerCase() + const canVote = isActive && !isExpired && !!viewer && !isFiler && !isTarget + + const needed = Math.ceil(totalMembers / 2) + const totalVotes = dispute.votes_for + dispute.votes_against + + return ( +
+
+ + {t(`status.${dispute.status}`)} + + {t(`types.${dispute.dispute_type}`)} + {isActive && !isExpired && ( + + {t("expiresIn", { time: formatRemaining(dispute.expires_at) })} + + )} +
+ +

{dispute.description}

+ + {(dispute.target_address || dispute.evidence_urls.length > 0) && ( +
+ {dispute.target_address && ( +

+ {t("against")}:{" "} + {shorten(dispute.target_address)} +

+ )} + {dispute.evidence_urls.map((url) => ( + + + {url} + + ))} +
+ )} + + {(dispute.status === "resolved_upheld" || dispute.status === "resolved_dismissed") && + dispute.resolution && ( +
+

{t("resolutionNote")}

+

{dispute.resolution}

+
+ )} + +
+
+ + {t("voteCounts", { for: dispute.votes_for, against: dispute.votes_against, needed })} + {isActive && !isExpired && ( + ({t("votesCast", { count: totalVotes })}) + )} +
+ +
+ {canVote && ( + <> + + + + )} + {isAdmin && isActive && ( + + )} +
+
+ + + + + + + {t("resolveTitle")} + + {t("resolveDescription")} + +