From 5aae122e15bd187fe1d4cba5e41e3509e7407b28 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 22 Sep 2026 04:52:50 +0000 Subject: [PATCH] Let a creator archive or delete a bounty Bounties had a status column (open/paused/closed) but no control anywhere to change it, and no way to remove one. This adds both. Archive is a new `archived` status. It keeps the bounty and every submission, invoice and payout record, hides the bounty from the public list and detail page (RLS, same as closed) and moves it to its own Archived tab on /dashboard/bounties. Unarchive sets it back to closed so it does not silently reopen for submissions. Delete is DELETE /api/bounties/:id, creator only. It is refused with 409 (and `archive_instead: true`) once any submission has been approved, invoiced or paid, because the cascade would erase payment history; the UI then offers to archive. Pending and rejected submissions delete with the bounty. The new BountyActions menu mirrors GigActions (pause/resume/close, archive/unarchive, delete) and sits on the bounty detail page and each dashboard row. When the bounty funds a GitHub issue, the status comment is flipped to "withdrawn" on archive or delete and restored on reopen; the comment bodies now live in one module. Migration applied to the live project as 20260922045204. openapi.json and skill.md document the new status and endpoint. Co-Authored-By: Claude Fable 5.1 --- public/openapi.json | 70 +++++- public/skill.md | 3 +- src/app/api/bounties/[id]/route.test.ts | 224 ++++++++++++++++++ src/app/api/bounties/[id]/route.ts | 97 +++++++- src/app/api/bounties/route.ts | 15 +- src/app/bounties/[id]/page.tsx | 24 +- src/app/dashboard/bounties/page.tsx | 77 ++++-- src/components/bounties/BountyActions.tsx | 209 ++++++++++++++++ src/lib/bounties-delete-guard.test.ts | 64 +++++ src/lib/bounties.ts | 38 ++- src/lib/bounty-issue-comments.ts | 37 +++ .../20260922045204_bounty_archived_status.sql | 15 ++ 12 files changed, 820 insertions(+), 53 deletions(-) create mode 100644 src/app/api/bounties/[id]/route.test.ts create mode 100644 src/components/bounties/BountyActions.tsx create mode 100644 src/lib/bounties-delete-guard.test.ts create mode 100644 src/lib/bounty-issue-comments.ts create mode 100644 supabase/migrations/20260922045204_bounty_archived_status.sql diff --git a/public/openapi.json b/public/openapi.json index f284e5c1..4ca8f7c1 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -168,7 +168,7 @@ "payout_currency": { "type": "string" }, "payment_coin": { "type": "string", "nullable": true }, "max_submissions": { "type": "integer", "nullable": true }, - "status": { "type": "string", "enum": ["open", "paused", "closed"] }, + "status": { "type": "string", "enum": ["open", "paused", "closed", "archived"] }, "closes_at": { "type": "string", "format": "date-time", "nullable": true }, "questions": { "type": "array", @@ -3211,8 +3211,8 @@ { "name": "status", "in": "query", - "schema": { "type": "string", "enum": ["open", "paused", "closed"], "default": "open" }, - "description": "Which bounties to return. Omitting it means `open`, so paused and closed bounties are not returned unless asked for." + "schema": { "type": "string", "enum": ["open", "paused", "closed", "archived"], "default": "open" }, + "description": "Which bounties to return. Omitting it means `open`, so paused and closed bounties are not returned unless asked for. Closed and archived bounties are visible only to their creator." }, { "name": "page", @@ -3335,7 +3335,7 @@ "patch": { "tags": ["Bounties"], "summary": "Update a bounty", - "description": "Creator only. Every field is optional — send just the ones you are changing. Setting `status` to `closed` stops new submissions.", + "description": "Creator only. Every field is optional — send just the ones you are changing. Setting `status` to `closed` stops new submissions. Setting it to `archived` also hides the bounty from everyone but you while keeping every submission and payout record; set it back to `closed` or `open` to unarchive.", "operationId": "updateBounty", "security": [{ "bearerAuth": [] }, { "apiKey": [] }], "parameters": [ @@ -3356,7 +3356,7 @@ { "type": "object", "properties": { - "status": { "type": "string", "enum": ["open", "paused", "closed"] } + "status": { "type": "string", "enum": ["open", "paused", "closed", "archived"] } } } ] @@ -3395,6 +3395,66 @@ } } } + }, + "delete": { + "tags": ["Bounties"], + "summary": "Delete a bounty", + "description": "Creator only. Permanently removes the bounty and its pending or rejected submissions. Refused with `409` once any submission has been approved, invoiced or paid, because those rows are payment history; archive the bounty instead (`PATCH` with `status: \"archived\"`).", + "operationId": "deleteBounty", + "security": [{ "bearerAuth": [] }, { "apiKey": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" } + } + ], + "responses": { + "200": { + "description": "Bounty deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } + } + }, + "403": { + "description": "Not the bounty creator", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } + } + }, + "404": { + "description": "Bounty not found", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } + } + }, + "409": { + "description": "Has approved, invoiced or paid submissions; archive instead", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "archive_instead": { "type": "boolean" } + } + } + } + } + } + } } }, "/api/bounties/{id}/submissions": { diff --git a/public/skill.md b/public/skill.md index 9d475034..a5ac06ac 100644 --- a/public/skill.md +++ b/public/skill.md @@ -226,7 +226,8 @@ the shortest path to a first paid transaction. | GET | `/api/bounties` | List bounties (`?status=&page=&limit=`). Defaults to `status=open` | | POST | `/api/bounties` | Create a bounty (needs at least one question) | | GET | `/api/bounties/:id` | Get a bounty, including its `questions` | -| PATCH | `/api/bounties/:id` | Update a bounty (creator only) | +| PATCH | `/api/bounties/:id` | Update a bounty (creator only). `status` is `open`, `paused`, `closed` or `archived` | +| DELETE | `/api/bounties/:id` | Delete a bounty (creator only). `409` once anything was approved or paid: archive it instead | | GET | `/api/bounties/:id/submissions` | Creator sees all submissions; everyone else sees their own | | POST | `/api/bounties/:id/submissions` | Submit answers | | PATCH | `/api/bounties/:id/submissions/:sid` | Approve or reject a submission (creator only) | diff --git a/src/app/api/bounties/[id]/route.test.ts b/src/app/api/bounties/[id]/route.test.ts new file mode 100644 index 00000000..98e5e24e --- /dev/null +++ b/src/app/api/bounties/[id]/route.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/github-app", () => ({ + updateIssueComment: vi.fn().mockResolvedValue(true), +})); + +import { DELETE, PATCH } from "./route"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { updateIssueComment } from "@/lib/github-app"; + +const mockGetAuthContext = vi.mocked(getAuthContext); +const mockUpdateIssueComment = vi.mocked(updateIssueComment); + +const BOUNTY: { + id: string; + creator_id: string; + title: string; + status: string; + payout_usd: number; + payment_coin: string | null; + github_issue_url: string | null; + github_comment_id: number | null; +} = { + id: "bounty-1", + creator_id: "creator-1", + title: "Write the docs", + status: "open", + payout_usd: 25, + payment_coin: "SOL", + github_issue_url: "https://github.com/acme/widgets/issues/7", + github_comment_id: 4242, +}; + +function makeParams() { + return { params: Promise.resolve({ id: "bounty-1" }) }; +} + +function deleteRequest() { + return new NextRequest("http://localhost/api/bounties/bounty-1", { method: "DELETE" }); +} + +function patchRequest(body: unknown) { + return new NextRequest("http://localhost/api/bounties/bounty-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * A supabase stub keyed by table. `bounties` answers the ownership lookup + * (and the update, for PATCH); `bounty_submissions` answers the payout scan. + */ +function makeSupabase(opts: { + bounty?: typeof BOUNTY | null; + submissions?: Array<{ status: string; payout_status: string }>; + deleteError?: { message: string } | null; +}) { + const bounty = opts.bounty === undefined ? BOUNTY : opts.bounty; + const deleteEq = vi.fn().mockResolvedValue({ error: opts.deleteError ?? null }); + const bountiesChain = { + select: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: bounty, error: null }), + delete: vi.fn(() => ({ eq: deleteEq })), + }; + const submissionsChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockResolvedValue({ data: opts.submissions ?? [], error: null }), + }; + const from = vi.fn((table: string) => + table === "bounty_submissions" ? submissionsChain : bountiesChain + ); + return { client: { from }, bountiesChain, submissionsChain, deleteEq }; +} + +describe("DELETE /api/bounties/[id]", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 401 when unauthenticated", async () => { + mockGetAuthContext.mockResolvedValue(null); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(401); + }); + + it("returns 404 when the bounty does not exist", async () => { + const { client } = makeSupabase({ bounty: null }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(404); + }); + + it("returns 403 for anyone but the creator", async () => { + const { client, deleteEq } = makeSupabase({}); + mockGetAuthContext.mockResolvedValue({ user: { id: "someone-else" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(403); + expect(deleteEq).not.toHaveBeenCalled(); + }); + + it("refuses with 409 once a submission has been paid, and points at archiving", async () => { + const { client, deleteEq } = makeSupabase({ + submissions: [ + { status: "approved", payout_status: "paid" }, + { status: "pending", payout_status: "unpaid" }, + ], + }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.archive_instead).toBe(true); + expect(body.error).toMatch(/1 paid or invoiced submission/); + expect(body.error).toMatch(/archive it instead/i); + expect(deleteEq).not.toHaveBeenCalled(); + }); + + it("refuses with 409 when an approved submission is still owed", async () => { + const { client, deleteEq } = makeSupabase({ + submissions: [{ status: "approved", payout_status: "unpaid" }], + }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(409); + expect((await res.json()).error).toMatch(/1 approved submission still unpaid/); + expect(deleteEq).not.toHaveBeenCalled(); + }); + + it("deletes a bounty with only pending or rejected submissions and withdraws the issue comment", async () => { + const { client, deleteEq } = makeSupabase({ + submissions: [ + { status: "pending", payout_status: "unpaid" }, + { status: "rejected", payout_status: "unpaid" }, + ], + }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(200); + expect(deleteEq).toHaveBeenCalledWith("id", "bounty-1"); + expect(mockUpdateIssueComment).toHaveBeenCalledWith( + "acme", + "widgets", + 4242, + expect.stringContaining("Bounty withdrawn") + ); + }); + + it("skips the GitHub comment when the bounty funds no issue", async () => { + const { client } = makeSupabase({ + bounty: { ...BOUNTY, github_issue_url: null, github_comment_id: null }, + }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await DELETE(deleteRequest(), makeParams()); + expect(res.status).toBe(200); + expect(mockUpdateIssueComment).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/bounties/[id] status", () => { + beforeEach(() => vi.clearAllMocks()); + + it("accepts archived and withdraws the issue comment", async () => { + const { client, bountiesChain } = makeSupabase({}); + bountiesChain.single + .mockResolvedValueOnce({ data: BOUNTY, error: null }) + .mockResolvedValueOnce({ data: { ...BOUNTY, status: "archived" }, error: null }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await PATCH(patchRequest({ status: "archived" }), makeParams()); + expect(res.status).toBe(200); + expect(bountiesChain.update).toHaveBeenCalledWith( + expect.objectContaining({ status: "archived" }) + ); + expect(mockUpdateIssueComment).toHaveBeenCalledWith( + "acme", + "widgets", + 4242, + expect.stringContaining("Bounty withdrawn") + ); + }); + + it("restores the posted comment when an archived bounty is reopened", async () => { + const { client, bountiesChain } = makeSupabase({ bounty: { ...BOUNTY, status: "archived" } }); + bountiesChain.single + .mockResolvedValueOnce({ data: { ...BOUNTY, status: "archived" }, error: null }) + .mockResolvedValueOnce({ data: { ...BOUNTY, status: "open" }, error: null }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await PATCH(patchRequest({ status: "open" }), makeParams()); + expect(res.status).toBe(200); + expect(mockUpdateIssueComment).toHaveBeenCalledWith( + "acme", + "widgets", + 4242, + expect.stringContaining("Bounty posted") + ); + }); + + it("leaves the comment alone for a pause", async () => { + const { client, bountiesChain } = makeSupabase({}); + bountiesChain.single + .mockResolvedValueOnce({ data: BOUNTY, error: null }) + .mockResolvedValueOnce({ data: { ...BOUNTY, status: "paused" }, error: null }); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await PATCH(patchRequest({ status: "paused" }), makeParams()); + expect(res.status).toBe(200); + expect(mockUpdateIssueComment).not.toHaveBeenCalled(); + }); + + it("rejects an unknown status", async () => { + const { client } = makeSupabase({}); + mockGetAuthContext.mockResolvedValue({ user: { id: "creator-1" }, supabase: client } as any); + const res = await PATCH(patchRequest({ status: "deleted" }), makeParams()); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/bounties/[id]/route.ts b/src/app/api/bounties/[id]/route.ts index f3aaed5a..884e0dcb 100644 --- a/src/app/api/bounties/[id]/route.ts +++ b/src/app/api/bounties/[id]/route.ts @@ -1,7 +1,37 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { getAuthContext } from "@/lib/auth/get-user"; -import { updateBountySchema } from "@/lib/bounties"; +import { updateBountySchema, bountyDeleteBlockReason } from "@/lib/bounties"; +import { + bountyPostedCommentBody, + bountyWithdrawnCommentBody, +} from "@/lib/bounty-issue-comments"; +import { parseGitHubIssueUrl } from "@/lib/github-links"; +import { updateIssueComment } from "@/lib/github-app"; + +interface CommentedBounty { + id: string; + title: string; + payout_usd: number | string; + payment_coin: string | null; + github_issue_url: string | null; + github_comment_id: number | null; +} + +// Best-effort edit of the GitHub issue status comment. Archiving or deleting +// leaves a "Claim this bounty" link that no longer resolves, so the comment is +// flipped to "withdrawn"; reopening flips it back. Never blocks the request. +async function syncIssueComment(bounty: CommentedBounty, kind: "posted" | "withdrawn") { + if (!bounty.github_issue_url || !bounty.github_comment_id) return; + const coords = parseGitHubIssueUrl(bounty.github_issue_url); + if (!coords) return; + const body = + kind === "withdrawn" ? bountyWithdrawnCommentBody(bounty) : bountyPostedCommentBody(bounty); + await updateIssueComment(coords.owner, coords.repo, bounty.github_comment_id, body); +} + +const OWNER_FIELDS = + "id, creator_id, title, status, payout_usd, payment_coin, github_issue_url, github_comment_id"; // GET /api/bounties/[id] — single bounty, public if open export async function GET( @@ -56,7 +86,7 @@ export async function PATCH( const { data: existing } = await (supabase as any) .from("bounties") - .select("creator_id") + .select(OWNER_FIELDS) .eq("id", id) .single(); if (!existing) { @@ -76,8 +106,71 @@ export async function PATCH( if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); } + + const next = parsed.data.status; + if (next && next !== existing.status) { + if (next === "archived") { + await syncIssueComment(data, "withdrawn"); + } else if (next === "open" && existing.status === "archived") { + await syncIssueComment(data, "posted"); + } + } + return NextResponse.json({ data }); } catch { return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); } } + +// DELETE /api/bounties/[id] — creator only. Refused once a submission has +// been approved, invoiced or paid: those rows are payment history and the +// cascade would erase them. Archive (PATCH status=archived) instead. +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + const { data: existing } = await (supabase as any) + .from("bounties") + .select(OWNER_FIELDS) + .eq("id", id) + .single(); + if (!existing) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } + if (existing.creator_id !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { data: submissions, error: subsError } = await (supabase as any) + .from("bounty_submissions") + .select("status, payout_status") + .eq("bounty_id", id); + if (subsError) { + return NextResponse.json({ error: subsError.message }, { status: 400 }); + } + + const blocked = bountyDeleteBlockReason(submissions || []); + if (blocked) { + return NextResponse.json({ error: blocked, archive_instead: true }, { status: 409 }); + } + + const { error } = await (supabase as any).from("bounties").delete().eq("id", id); + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + await syncIssueComment(existing, "withdrawn"); + + return NextResponse.json({ message: "Bounty deleted" }); + } catch { + return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); + } +} diff --git a/src/app/api/bounties/route.ts b/src/app/api/bounties/route.ts index 7708699e..d98cab67 100644 --- a/src/app/api/bounties/route.ts +++ b/src/app/api/bounties/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { randomUUID } from "crypto"; import { createClient } from "@/lib/supabase/server"; import { getAuthContext } from "@/lib/auth/get-user"; -import { createBountySchema, formatBountyPayout } from "@/lib/bounties"; +import { createBountySchema, BOUNTY_STATUSES, type BountyStatus } from "@/lib/bounties"; +import { bountyPostedCommentBody } from "@/lib/bounty-issue-comments"; import { parseGitHubIssueUrl } from "@/lib/github-links"; import { postIssueComment } from "@/lib/github-app"; @@ -17,19 +18,9 @@ async function postBountyIssueComment(bounty: { }): Promise { const coords = parseGitHubIssueUrl(bounty.github_issue_url); if (!coords) return null; - const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, ""); - const bountyUrl = `${appUrl}/bounties/${bounty.id}`; - const body = - `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + - `**${bounty.title}**\n\n` + - `[Claim this bounty →](${bountyUrl})\n\n` + - `Posted automatically by ugig.net.`; - return postIssueComment(coords.owner, coords.repo, coords.number, body); + return postIssueComment(coords.owner, coords.repo, coords.number, bountyPostedCommentBody(bounty)); } -const BOUNTY_STATUSES = ["open", "paused", "closed"] as const; -type BountyStatus = (typeof BOUNTY_STATUSES)[number]; - // GET /api/bounties — public list of bounties export async function GET(request: NextRequest) { try { diff --git a/src/app/bounties/[id]/page.tsx b/src/app/bounties/[id]/page.tsx index e5eee7d9..be9ef9ba 100644 --- a/src/app/bounties/[id]/page.tsx +++ b/src/app/bounties/[id]/page.tsx @@ -9,12 +9,13 @@ import { Users, Clock, Lock, - Pencil, Github, + Archive, } from "lucide-react"; import { MarkdownContent } from "@/components/ui/MarkdownContent"; import { PriceBox, PriceBoxRow } from "@/components/ui/PriceBox"; -import { formatBountyPayout } from "@/lib/bounties"; +import { formatBountyPayout, type BountyStatus } from "@/lib/bounties"; +import { BountyActions } from "@/components/bounties/BountyActions"; import { SubmitForm } from "./SubmitForm"; import { ReviewPanel } from "./ReviewPanel"; @@ -28,7 +29,7 @@ interface BountyDetail { payment_coin: string | null; max_submissions: number | null; github_issue_url: string | null; - status: "open" | "paused" | "closed"; + status: BountyStatus; questions: { id: string; type: "short_text" | "long_text" | "multiple_choice"; @@ -126,6 +127,16 @@ export default async function BountyDetailPage({
+ {isCreator && bounty.status === "archived" && ( +
+ +

+ This bounty is archived. Only you can see it, and it is not + accepting submissions. Every submission and payout record is + kept. Unarchive it from the actions menu to bring it back. +

+
+ )}
{bounty.status !== "open" && ( @@ -226,12 +237,7 @@ export default async function BountyDetailPage({ subtitle="Per approved submission" topRight={ isCreator ? ( - - - + ) : null } > diff --git a/src/app/dashboard/bounties/page.tsx b/src/app/dashboard/bounties/page.tsx index a6363a4f..7ba5e1e4 100644 --- a/src/app/dashboard/bounties/page.tsx +++ b/src/app/dashboard/bounties/page.tsx @@ -12,14 +12,15 @@ import { Clock, XCircle, } from "lucide-react"; -import { formatBountyPayout } from "@/lib/bounties"; +import { formatBountyPayout, type BountyStatus } from "@/lib/bounties"; +import { BountyActions } from "@/components/bounties/BountyActions"; export const metadata = { title: "My Bounties | ugig.net", description: "Manage bounties you've posted or submitted to", }; -type TabKey = "created" | "submitted"; +type TabKey = "created" | "archived" | "submitted"; export default async function DashboardBountiesPage({ searchParams, @@ -35,7 +36,8 @@ export default async function DashboardBountiesPage({ } const tabParam = (await searchParams).tab; - const tab: TabKey = tabParam === "submitted" ? "submitted" : "created"; + const tab: TabKey = + tabParam === "submitted" ? "submitted" : tabParam === "archived" ? "archived" : "created"; // Bounties I've created const { data: createdData } = await (supabase as any) @@ -43,18 +45,22 @@ export default async function DashboardBountiesPage({ .select("id, title, payout_usd, payment_coin, max_submissions, status, created_at") .eq("creator_id", user.id) .order("created_at", { ascending: false }); - const created = (createdData || []) as Array<{ + const allCreated = (createdData || []) as Array<{ id: string; title: string; payout_usd: number; payment_coin: string | null; max_submissions: number | null; - status: string; + status: BountyStatus; created_at: string; }>; + // Archived bounties keep their records but live on their own tab. + const created = allCreated.filter((b) => b.status !== "archived"); + const archived = allCreated.filter((b) => b.status === "archived"); + const shown = tab === "archived" ? archived : created; // Submission counts per bounty I created (best-effort, single query) - const createdIds = created.map((b) => b.id); + const createdIds = allCreated.map((b) => b.id); const submissionStats: Record< string, { total: number; pending: number; approved_unpaid: number } @@ -136,6 +142,18 @@ export default async function DashboardBountiesPage({ > Bounties I posted ({created.length}) + {archived.length > 0 && ( + + Archived ({archived.length}) + + )}
- {tab === "created" ? ( - created.length === 0 ? ( + {tab !== "submitted" ? ( + shown.length === 0 ? (
-

No bounties yet

+

+ {tab === "archived" ? "No archived bounties" : "No bounties yet"} +

- Post your first bounty to start collecting submissions. + {tab === "archived" + ? "Archived bounties keep their submissions and payouts but stay out of the way." + : "Post your first bounty to start collecting submissions."}

- - - + {tab !== "archived" && ( + + + + )}
) : (
- {created.map((b) => { + {shown.map((b) => { const stat = submissionStats[b.id] || { total: 0, pending: 0, approved_unpaid: 0, }; return ( -
-
-

{b.title}

+
+ + {b.title} +

Posted {new Date(b.created_at).toLocaleDateString()}

- - {b.status} - +
+ + {b.status} + + +
@@ -205,7 +236,7 @@ export default async function DashboardBountiesPage({ )}
- +
); })}
diff --git a/src/components/bounties/BountyActions.tsx b/src/components/bounties/BountyActions.tsx new file mode 100644 index 00000000..f04c9f12 --- /dev/null +++ b/src/components/bounties/BountyActions.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { useDialog } from "@/components/providers/DialogProvider"; +import type { BountyStatus } from "@/lib/bounties"; +import { + MoreHorizontal, + Pencil, + Trash2, + Pause, + Play, + XCircle, + Archive, + ArchiveRestore, + Loader2, +} from "lucide-react"; + +interface BountyActionsProps { + bountyId: string; + status: BountyStatus; + /** Where to go after a delete. Defaults to the bounties dashboard. */ + afterDeleteHref?: string; + /** Hide the Edit button (the dashboard list keeps the row compact). */ + hideEdit?: boolean; +} + +// Creator-only controls for a bounty: pause/resume/close, archive/unarchive +// and delete. Same shape as GigActions so the two marketplaces feel alike. +// +// Archive keeps every submission and payout record and hides the bounty from +// everyone but the creator. Delete is refused by the API once a submission +// has been approved, invoiced or paid; the menu then offers to archive. +export function BountyActions({ + bountyId, + status, + afterDeleteHref = "/dashboard/bounties", + hideEdit = false, +}: BountyActionsProps) { + const router = useRouter(); + const { confirm, alert } = useDialog(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const setStatus = async (next: BountyStatus) => { + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/bounties/${bountyId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setError(json.error || "Could not update bounty"); + return; + } + setIsOpen(false); + router.refresh(); + } finally { + setIsLoading(false); + } + }; + + const handleArchive = async () => { + if ( + !(await confirm( + "Archive this bounty? It disappears from the public list and your active bounties. Submissions and payout records are kept, and you can unarchive it later." + )) + ) { + return; + } + await setStatus("archived"); + }; + + const handleDelete = async () => { + if ( + !(await confirm( + "Delete this bounty? Pending and rejected submissions are deleted with it. This cannot be undone." + )) + ) { + return; + } + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/bounties/${bountyId}`, { method: "DELETE" }); + const json = await res.json().catch(() => ({})); + if (res.status === 409 && json.archive_instead) { + setIsOpen(false); + if (await confirm(`${json.error}\n\nArchive it now?`)) { + await setStatus("archived"); + } + return; + } + if (!res.ok) { + setError(json.error || "Could not delete bounty"); + return; + } + setIsOpen(false); + router.push(afterDeleteHref); + router.refresh(); + } finally { + setIsLoading(false); + } + }; + + const item = "w-full px-3 py-2 text-left text-sm hover:bg-muted rounded flex items-center gap-2"; + + return ( +
+ {!hideEdit && ( + + + + )} + +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+ {error &&
{error}
} + + {status === "open" && ( + <> + + + + )} + + {status === "paused" && ( + <> + + + + )} + + {status === "closed" && ( + + )} + + {status === "archived" ? ( + + ) : ( + + )} + +
+ + +
+
+ + )} +
+
+ ); +} diff --git a/src/lib/bounties-delete-guard.test.ts b/src/lib/bounties-delete-guard.test.ts new file mode 100644 index 00000000..b10c46b2 --- /dev/null +++ b/src/lib/bounties-delete-guard.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { bountyDeleteBlockReason, BOUNTY_STATUSES, updateBountySchema } from "./bounties"; + +describe("bountyDeleteBlockReason", () => { + it("allows deletion with no submissions", () => { + expect(bountyDeleteBlockReason([])).toBeNull(); + }); + + it("allows deletion when every submission is pending or rejected and unpaid", () => { + expect( + bountyDeleteBlockReason([ + { status: "pending", payout_status: "unpaid" }, + { status: "rejected", payout_status: "unpaid" }, + ]) + ).toBeNull(); + }); + + it("blocks on a paid submission", () => { + expect( + bountyDeleteBlockReason([{ status: "approved", payout_status: "paid" }]) + ).toMatch(/1 paid or invoiced submission\./); + }); + + it("blocks on an invoiced submission", () => { + expect( + bountyDeleteBlockReason([{ status: "approved", payout_status: "invoiced" }]) + ).toMatch(/paid or invoiced/); + }); + + it("blocks on an approved submission that is still owed", () => { + expect( + bountyDeleteBlockReason([{ status: "approved", payout_status: "unpaid" }]) + ).toMatch(/1 approved submission still unpaid/); + }); + + it("does not count a rejected payout as money moved", () => { + // payout_status 'rejected' means the creator refused to pay after approval + // (e.g. no wallet to send to); nothing was invoiced or sent, so nothing + // is owed and no payment record is lost by deleting. + expect( + bountyDeleteBlockReason([{ status: "approved", payout_status: "rejected" }]) + ).toBeNull(); + }); + + it("names both counts, pluralised", () => { + expect( + bountyDeleteBlockReason([ + { status: "approved", payout_status: "paid" }, + { status: "approved", payout_status: "paid" }, + { status: "approved", payout_status: "unpaid" }, + ]) + ).toBe( + "This bounty has 2 paid or invoiced submissions and 1 approved submission still unpaid. Payment records are kept, so it cannot be deleted; archive it instead." + ); + }); +}); + +describe("bounty status", () => { + it("includes archived", () => { + expect(BOUNTY_STATUSES).toContain("archived"); + expect(updateBountySchema.safeParse({ status: "archived" }).success).toBe(true); + expect(updateBountySchema.safeParse({ status: "deleted" }).success).toBe(false); + }); +}); diff --git a/src/lib/bounties.ts b/src/lib/bounties.ts index ba8ac055..f68ef3d7 100644 --- a/src/lib/bounties.ts +++ b/src/lib/bounties.ts @@ -43,10 +43,46 @@ export const createBountySchema = z.object({ questions: z.array(questionSchema).min(1).max(20), }); +/** + * Every bounty status. `archived` keeps the bounty and its submissions but + * hides it from the public list, the public detail page and the creator's + * active dashboard list; it is the reversible alternative to deleting. + */ +export const BOUNTY_STATUSES = ["open", "paused", "closed", "archived"] as const; +export type BountyStatus = (typeof BOUNTY_STATUSES)[number]; + export const updateBountySchema = createBountySchema.partial().extend({ - status: z.enum(["open", "paused", "closed"]).optional(), + status: z.enum(BOUNTY_STATUSES).optional(), }); +/** + * Why a bounty cannot be hard-deleted, or null when it can. + * + * Deleting cascades to `bounty_submissions`, which is where invoices and + * payouts are recorded. Once a submission has been approved (money owed) or + * invoiced/paid (money moved) the rows are financial history and the bounty + * must be archived instead. Pending and rejected submissions do not block. + */ +export function bountyDeleteBlockReason( + submissions: Array<{ status: string; payout_status: string }> +): string | null { + const paid = submissions.filter( + (s) => s.payout_status === "paid" || s.payout_status === "invoiced" + ).length; + const owed = submissions.filter( + (s) => s.status === "approved" && s.payout_status === "unpaid" + ).length; + if (paid === 0 && owed === 0) return null; + const parts: string[] = []; + if (paid > 0) { + parts.push(`${paid} paid or invoiced submission${paid === 1 ? "" : "s"}`); + } + if (owed > 0) { + parts.push(`${owed} approved submission${owed === 1 ? "" : "s"} still unpaid`); + } + return `This bounty has ${parts.join(" and ")}. Payment records are kept, so it cannot be deleted; archive it instead.`; +} + export const answerSchema = z.object({ question_id: z.string(), value: z.union([z.string(), z.array(z.string())]), diff --git a/src/lib/bounty-issue-comments.ts b/src/lib/bounty-issue-comments.ts new file mode 100644 index 00000000..83f1fe92 --- /dev/null +++ b/src/lib/bounty-issue-comments.ts @@ -0,0 +1,37 @@ +import { formatBountyPayout } from "@/lib/bounties"; + +// The status comment ugig posts on a GitHub issue a bounty funds. One body +// per lifecycle moment so the wording stays in one place wherever it is +// edited: creation in the bounties route, archive/delete/reopen in the +// bounty route, "paid" in the CoinPay webhook. + +export function bountyAppUrl(): string { + return (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, ""); +} + +interface CommentBounty { + id: string; + title: string; + payout_usd: number | string; + payment_coin: string | null; +} + +export function bountyPostedCommentBody(bounty: CommentBounty): string { + const appUrl = bountyAppUrl(); + const bountyUrl = `${appUrl}/bounties/${bounty.id}`; + return ( + `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + + `**${bounty.title}**\n\n` + + `[Claim this bounty →](${bountyUrl})\n\n` + + `Posted automatically by ugig.net.` + ); +} + +/** The bounty was archived or deleted, so the claim link no longer resolves. */ +export function bountyWithdrawnCommentBody(bounty: Pick): string { + const appUrl = bountyAppUrl(); + return ( + `🗄️ **Bounty withdrawn on [ugig.net](${appUrl})** — "${bounty.title}" is no longer open for submissions.\n\n` + + `Updated automatically by ugig.net.` + ); +} diff --git a/supabase/migrations/20260922045204_bounty_archived_status.sql b/supabase/migrations/20260922045204_bounty_archived_status.sql new file mode 100644 index 00000000..6eb7c760 --- /dev/null +++ b/supabase/migrations/20260922045204_bounty_archived_status.sql @@ -0,0 +1,15 @@ +-- Bounties gain an `archived` status. Archiving keeps the bounty and every +-- submission (approvals, invoices, payouts) but hides it from the public +-- list, the public detail page and the creator's active dashboard list. +-- Hard deletion stays DELETE /api/bounties/:id, refused once money has moved. + +ALTER TABLE bounties DROP CONSTRAINT IF EXISTS bounties_status_check; +ALTER TABLE bounties + ADD CONSTRAINT bounties_status_check + CHECK (status IN ('open', 'paused', 'closed', 'archived')); + +-- Archived bounties are creator-only, like closed ones. +DROP POLICY IF EXISTS "Bounties are publicly readable when not closed" ON bounties; +CREATE POLICY "Bounties are publicly readable when not closed" + ON bounties FOR SELECT + USING (status NOT IN ('closed', 'archived') OR auth.uid() = creator_id);