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);