From e5650356730856a8956cc3be6159bda90775965d Mon Sep 17 00:00:00 2001 From: s3curi7y Date: Wed, 13 May 2026 15:41:59 +0800 Subject: [PATCH] feat: split bounty payouts across PR contributors --- .../issues/[issueNumber]/approve-button.tsx | 6 +- lib/api/client.ts | 9 + lib/bounty/services/approve-payout.ts | 217 ++++++++++--- lib/bounty/services/payout-split.test.ts | 101 ++++++ lib/bounty/services/payout-split.ts | 82 +++++ lib/bounty/services/payout.ts | 2 +- package.json | 4 +- pnpm-lock.yaml | 292 ++++++++++++++++++ supabase/schema.sql | 2 + 9 files changed, 662 insertions(+), 53 deletions(-) create mode 100644 lib/bounty/services/payout-split.test.ts create mode 100644 lib/bounty/services/payout-split.ts diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..cf86791 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -26,10 +26,12 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) { startTransition(async () => { try { const response = await approveBounty({ owner, repo, issueNumber }); - const { payoutType, recipientEmail, recipientWallet } = response.payout; + const { payoutType, recipientEmail, recipientWallet, payouts } = response.payout; let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (payouts && payouts.length > 1) { + message = `Payout split across ${payouts.length} contributors.`; + } else if (payoutType === "wallet" && recipientWallet) { message = `Payout sent to wallet ${recipientWallet.slice(0, 6)}...${recipientWallet.slice(-4)}`; } else if (payoutType === "email" && recipientEmail) { message = `Payout sent to ${recipientEmail}`; diff --git a/lib/api/client.ts b/lib/api/client.ts index d78c429..8ddb175 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -165,6 +165,15 @@ export async function approveBounty(params: { recipientWallet: string | null; txHash: string | null; transactionId: string; + payouts?: Array<{ + recipient: string; + amount: number; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail: string | null; + recipientWallet: string | null; + txHash: string | null; + transactionId: string; + }>; approvedBy: string; }; }> { diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..27c3dbc 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -1,10 +1,21 @@ import "server-only"; -import { resolveAndPayout } from "@/lib/bounty/services/payout"; +import { resolveAndPayout, type PayoutResult } from "@/lib/bounty/services/payout"; import { syncGithubBountyArtifacts } from "@/lib/bounty/services/github-sync"; import { getSupabaseServiceClient } from "@/lib/clients/supabase/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; import { buildIssueId } from "@/lib/bounty/issue-id"; +import { buildPayoutShares, type PayoutShare, type PayoutSplitCommit } from "@/lib/bounty/services/payout-split"; + +type PayoutExecution = { + share: PayoutShare; + result: PayoutResult; +}; + +type PayoutEventDraft = { + id: string; + share: PayoutShare; +}; export async function approveBountyPayout(params: { owner: string; @@ -41,40 +52,156 @@ export async function approveBountyPayout(params: { throw new Error("No winning PR author found for payout"); } + const { data: existingPayoutEvents, error: existingPayoutEventsError } = await supabase + .from("payout_events") + .select("id, status") + .eq("issue_id", issueId); + + if (existingPayoutEventsError) { + throw new Error(`Failed to inspect existing payout events: ${existingPayoutEventsError.message}`); + } + + if (existingPayoutEvents && existingPayoutEvents.length > 0) { + throw new Error("Payout events already exist for this bounty. Manual review is required before retrying payout."); + } + let winningPrBody: string | null = null; + let winningPrCommits: PayoutSplitCommit[] = []; if (bounty.winning_pr_number) { try { const installationId = await getGithubRepoInstallationId(params.owner, params.repo); const github = await getGithubInstallationClient(installationId); - const prResponse = await github.rest.pulls.get({ - owner: params.owner, - repo: params.repo, - pull_number: bounty.winning_pr_number, - }); + const [prResponse, commits] = await Promise.all([ + github.rest.pulls.get({ + owner: params.owner, + repo: params.repo, + pull_number: bounty.winning_pr_number, + }), + github.paginate(github.rest.pulls.listCommits, { + owner: params.owner, + repo: params.repo, + pull_number: bounty.winning_pr_number, + per_page: 100, + }), + ]); winningPrBody = prResponse.data.body ?? null; + winningPrCommits = commits; } catch (err) { - console.warn("Failed to fetch PR body for wallet extraction:", err); + console.warn("Failed to fetch PR metadata for payout splitting:", err); } } - // only single payout for now, later multiple payouts - const payoutResult = await resolveAndPayout({ - owner: params.owner, - repo: params.repo, - issueNumber: params.issueNumber, - winningPrAuthor: bounty.winning_pr_author, - winningPrBody, - amount: bounty.total_amount, - issueId, + const payoutShares = buildPayoutShares({ + primaryAuthor: bounty.winning_pr_author, + totalAmount: bounty.total_amount, + commits: winningPrCommits, }); + if (payoutShares.length === 0) { + throw new Error("No payout recipients could be resolved"); + } + const now = new Date().toISOString(); + const payoutEventRows = payoutShares.map((share) => ({ + issue_id: issueId, + recipient_username: share.username, + amount: share.amount, + status: "PENDING" as const, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + split_recipient_count: payoutShares.length, + }, + })); + + const { data: payoutEventDrafts, error: payoutEventError } = await supabase + .from("payout_events") + .insert(payoutEventRows) + .select("id, recipient_username, amount"); + + if (payoutEventError) { + throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); + } + + const payoutDrafts: PayoutEventDraft[] = payoutShares.map((share) => { + const draft = payoutEventDrafts?.find((event) => ( + event.recipient_username.toLowerCase() === share.username.toLowerCase() + && Math.round(event.amount * 100) === Math.round(share.amount * 100) + )); + + if (!draft) { + throw new Error(`Failed to match pending payout event for ${share.username}`); + } + + return { id: draft.id, share }; + }); + + const payoutResults: PayoutExecution[] = []; + for (const draft of payoutDrafts) { + let result: PayoutResult; + + try { + result = await resolveAndPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + winningPrAuthor: draft.share.username, + winningPrBody: draft.share.username.toLowerCase() === bounty.winning_pr_author.toLowerCase() ? winningPrBody : null, + amount: draft.share.amount, + issueId, + }); + } catch (error) { + const { error: failedEventError } = await supabase + .from("payout_events") + .update({ + status: "FAILED", + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + split_recipient_count: payoutShares.length, + error: error instanceof Error ? error.message : String(error), + }, + }) + .eq("id", draft.id); + + if (failedEventError) { + console.error("Failed to mark payout event as FAILED:", failedEventError); + } + + throw error; + } + + const { error: successEventError } = await supabase + .from("payout_events") + .update({ + locus_transaction_id: result.transactionId, + transaction_hash: result.txHash, + status: "SUCCESS", + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: result.payoutType, + recipient_email: result.recipientEmail, + recipient_wallet: result.recipientWallet, + split_recipient_count: payoutShares.length, + }, + }) + .eq("id", draft.id); + + if (successEventError) { + throw new Error(`Payout was sent, but failed to mark payout event as SUCCESS: ${successEventError.message}`); + } + + payoutResults.push({ share: draft.share, result }); + } + + const primaryPayout = payoutResults[0].result; const { error: updateError } = await supabase .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: primaryPayout.txHash, paid_at: now, approved_by: params.approvedBy, }) @@ -84,38 +211,21 @@ export async function approveBountyPayout(params: { throw new Error(`Failed to update bounty status to PAID: ${updateError.message}`); } - const { error: payoutEventError } = await supabase.from("payout_events").insert({ + const activityRows = payoutResults.map(({ share, result }) => ({ issue_id: issueId, - recipient_username: bounty.winning_pr_author, - amount: bounty.total_amount, - locus_transaction_id: payoutResult.transactionId, - transaction_hash: payoutResult.txHash, - status: "SUCCESS", + event_type: "PAYOUT_SENT" as const, + actor_username: share.username, + amount: share.amount, + tx_hash: result.txHash, metadata: { approved_by: params.approvedBy, payout_source: "web", - payout_type: payoutResult.payoutType, - recipient_email: payoutResult.recipientEmail, - recipient_wallet: payoutResult.recipientWallet, + payout_type: result.payoutType, + split_recipient_count: payoutResults.length, }, - }); - - if (payoutEventError) { - throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); - } + })); - const { error: activityError } = await supabase.from("activity_events").insert({ - issue_id: issueId, - event_type: "PAYOUT_SENT", - actor_username: bounty.winning_pr_author, - amount: bounty.total_amount, - tx_hash: payoutResult.txHash, - metadata: { - approved_by: params.approvedBy, - payout_source: "web", - payout_type: payoutResult.payoutType, - }, - }); + const { error: activityError } = await supabase.from("activity_events").insert(activityRows); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -127,11 +237,20 @@ export async function approveBountyPayout(params: { issueId, amount: bounty.total_amount, recipient: bounty.winning_pr_author, - payoutType: payoutResult.payoutType, - recipientEmail: payoutResult.recipientEmail, - recipientWallet: payoutResult.recipientWallet, - txHash: payoutResult.txHash, - transactionId: payoutResult.transactionId, + payoutType: primaryPayout.payoutType, + recipientEmail: primaryPayout.recipientEmail, + recipientWallet: primaryPayout.recipientWallet, + txHash: primaryPayout.txHash, + transactionId: primaryPayout.transactionId, + payouts: payoutResults.map(({ share, result }) => ({ + recipient: share.username, + amount: share.amount, + payoutType: result.payoutType, + recipientEmail: result.recipientEmail ?? null, + recipientWallet: result.recipientWallet ?? null, + txHash: result.txHash, + transactionId: result.transactionId, + })), approvedBy: params.approvedBy, }; -} \ No newline at end of file +} diff --git a/lib/bounty/services/payout-split.test.ts b/lib/bounty/services/payout-split.test.ts new file mode 100644 index 0000000..7f6e860 --- /dev/null +++ b/lib/bounty/services/payout-split.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildPayoutShares, extractCoAuthors, splitPayoutAmount, uniqueContributors } from "./payout-split"; + +test("extractCoAuthors parses GitHub co-author trailers", () => { + const contributors = extractCoAuthors(`feat: add split payouts + +Body text. + +Co-authored-by: Ada Lovelace +Co-authored-by: Grace Hopper `); + + assert.deepEqual(contributors, [ + { username: "ada", name: "Ada Lovelace", email: "ada@example.com" }, + { username: "grace", name: "Grace Hopper", email: "grace@example.com" }, + ]); +}); + +test("extractCoAuthors derives GitHub usernames from noreply co-author emails", () => { + const contributors = extractCoAuthors( + "Co-authored-by: Mona Lisa <123456+mona-lisa@users.noreply.github.com>", + ); + + assert.deepEqual(contributors, [ + { + username: "mona-lisa", + name: "Mona Lisa", + email: "123456+mona-lisa@users.noreply.github.com", + }, + ]); +}); + +test("uniqueContributors keeps the primary contributor first and removes duplicate usernames", () => { + const contributors = uniqueContributors([ + { username: "primary" }, + { username: "friend", email: "friend@example.com" }, + { username: "Primary", email: "other@example.com" }, + { username: "friend" }, + ]); + + assert.deepEqual(contributors, [ + { username: "primary" }, + { username: "friend", email: "friend@example.com" }, + ]); +}); + +test("splitPayoutAmount splits by integer cents and gives remainder to primary author", () => { + const shares = splitPayoutAmount(10, [ + { username: "primary" }, + { username: "friend" }, + { username: "third" }, + ]); + + assert.deepEqual(shares, [ + { username: "primary", amount: 3.34 }, + { username: "friend", amount: 3.33 }, + { username: "third", amount: 3.33 }, + ]); +}); + +test("buildPayoutShares combines primary author, commit authors, and co-author trailers", () => { + const shares = buildPayoutShares({ + primaryAuthor: "primary", + totalAmount: 5, + commits: [ + { + author: { login: "friend" }, + commit: { + message: `feat: shared work + +Co-authored-by: Grace Hopper `, + }, + }, + { + author: { login: "primary" }, + commit: { + message: "fix: follow up", + }, + }, + ], + }); + + assert.deepEqual(shares, [ + { username: "primary", amount: 1.67 }, + { username: "friend", amount: 1.67 }, + { username: "grace", name: "Grace Hopper", email: "grace@example.com", amount: 1.66 }, + ]); +}); + +test("buildPayoutShares falls back to the primary author when commit metadata is unavailable", () => { + const shares = buildPayoutShares({ + primaryAuthor: "primary", + totalAmount: 10, + commits: [], + }); + + assert.deepEqual(shares, [ + { username: "primary", amount: 10 }, + ]); +}); diff --git a/lib/bounty/services/payout-split.ts b/lib/bounty/services/payout-split.ts new file mode 100644 index 0000000..fe8b75f --- /dev/null +++ b/lib/bounty/services/payout-split.ts @@ -0,0 +1,82 @@ +export type PayoutContributor = { + username: string; + name?: string | null; + email?: string | null; +}; + +export type PayoutShare = PayoutContributor & { + amount: number; +}; + +type CommitAuthor = { + login?: string | null; +}; + +export type PayoutSplitCommit = { + commit?: { + message?: string | null; + }; + author?: CommitAuthor | null; +}; + +const CO_AUTHOR_REGEX = /^Co-authored-by:\s*(.*?)\s*<([^<>@\s]+@[^<>@\s]+)>$/gim; + +function usernameFromEmail(email: string): string { + const githubNoreplyMatch = /^.+\+(.+)@users\.noreply\.github\.com$/i.exec(email); + if (githubNoreplyMatch) return githubNoreplyMatch[1].toLowerCase(); + + return email.split("@")[0].toLowerCase(); +} + +export function extractCoAuthors(message: string): PayoutContributor[] { + return Array.from(message.matchAll(CO_AUTHOR_REGEX), (match) => ({ + username: usernameFromEmail(match[2]), + name: match[1].trim() || null, + email: match[2].toLowerCase(), + })); +} + +export function uniqueContributors(contributors: PayoutContributor[]): PayoutContributor[] { + const seen = new Set(); + const unique: PayoutContributor[] = []; + + for (const contributor of contributors) { + const key = contributor.username.toLowerCase(); + if (seen.has(key)) continue; + + seen.add(key); + unique.push(contributor); + } + + return unique; +} + +export function splitPayoutAmount(totalAmount: number, contributors: PayoutContributor[]): PayoutShare[] { + if (contributors.length === 0) return []; + + const totalCents = Math.round(totalAmount * 100); + const baseShareCents = Math.floor(totalCents / contributors.length); + let remainingCents = totalCents - baseShareCents * contributors.length; + + return contributors.map((contributor) => ({ + ...contributor, + amount: (baseShareCents + (remainingCents-- > 0 ? 1 : 0)) / 100, + })); +} + +export function buildPayoutShares(params: { + primaryAuthor: string; + totalAmount: number; + commits: PayoutSplitCommit[]; +}): PayoutShare[] { + const contributors = uniqueContributors([ + { username: params.primaryAuthor }, + ...params.commits.flatMap((commit) => { + const commitAuthor = commit.author?.login ? [{ username: commit.author.login }] : []; + const coAuthors = extractCoAuthors(commit.commit?.message ?? ""); + return [...commitAuthor, ...coAuthors]; + }), + ]); + + return splitPayoutAmount(params.totalAmount, contributors); +} diff --git a/lib/bounty/services/payout.ts b/lib/bounty/services/payout.ts index 3f40a1c..1f24346 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -173,4 +173,4 @@ export async function resolveAndPayout(params: { amount: params.amount, issueId: params.issueId, }); -} \ No newline at end of file +} diff --git a/package.json b/package.json index b1f502b..810dad0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test \"lib/**/*.test.ts\"" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -36,6 +37,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e167411..c02cd42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: tailwindcss: specifier: ^4 version: 4.2.4 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5 version: 5.9.3 @@ -270,6 +273,162 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1053,6 +1212,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1653,6 +1813,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1921,6 +2086,11 @@ packages: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -3352,6 +3522,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -3827,6 +4002,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -5198,6 +5451,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -5590,6 +5872,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -7353,6 +7638,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + tw-animate-css@1.4.0: {} type-check@0.4.0: diff --git a/supabase/schema.sql b/supabase/schema.sql index 088868a..af7a776 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -117,6 +117,8 @@ create index if not exists activity_events_created_at_idx on public.activity_eve create index if not exists payout_events_issue_id_idx on public.payout_events(issue_id); create index if not exists payout_events_recipient_username_idx on public.payout_events(recipient_username); +create unique index if not exists payout_events_issue_recipient_unique_idx + on public.payout_events(issue_id, lower(recipient_username)); create table if not exists public.webhook_deliveries ( id uuid primary key default gen_random_uuid(),