Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions features/bounties/hooks/use-bounty-escrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
'use client';

import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { reportError } from '@/lib/error-reporting';

const BOUNDED_PREFIX = ['bounties'] as const;

type BountySummary = {
id: string;
title: string;
description: string;
reward: string;
status: 'open' | 'in_progress' | 'completed';
createdAt: string;
claimCount?: number;
};

type BountyApplication = {
id: string;
bountyId: string;
message: string;
status: 'pending' | 'accepted' | 'rejected';
submittedAt: string;
};

type ParticipantActivityItem =
| { kind: 'application'; data: BountyApplication }
| { kind: 'submission'; data: { id: string; bountyId: string; status: string } };

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(body || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}

export function useBountiesList(params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: [...BOUNDED_PREFIX, 'list', params],
queryFn: async (): Promise<{ bounties: BountySummary[]; pagination: Record<string, unknown> }> => {
const search = new URLSearchParams({ page: String(params?.page ?? 1), limit: String(params?.limit ?? 20) });
const data = await request<{ bounties: BountySummary[]; pagination: Record<string, unknown> }>(`/api/bounties?${search}`);
return { bounties: data.bounties ?? [], pagination: data.pagination ?? {} };
},
staleTime: 30_000,
});
}

export function useBounty(id: string) {
return useQuery({
queryKey: [...BOUNDED_PREFIX, 'detail', id],
queryFn: async () => {
const data = await request<{ bounty: BountySummary }>(`/api/bounties/${encodeURIComponent(id)}`);
return data.bounty;
},
enabled: !!id,
staleTime: 30_000,
});
}

export function useMyBountyApplications(bountyId: string) {
return useQuery({
queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId],
queryFn: async () => {
const data = await request<{ applications: BountyApplication[] }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications/me`);
return data.applications ?? [];
},
enabled: !!bountyId,
staleTime: 20_000,
});
}

export function useApplyToBounty(bountyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (message: string) => {
const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications`, {
method: 'POST',
body: JSON.stringify({ message }),
});
return data.application;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] });
toast.success('Application submitted');
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to apply';
reportError(err, { context: 'bounties-applyToBounty', bountyId });
toast.error(message);
},
});
}

export function useJoinCompetition(bountyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload?: { teamName?: string; message?: string }) => {
const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/join`, {
method: 'POST',
body: JSON.stringify({ type: 'TEAM', ...payload }),
});
return data.application;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] });
toast.success('Joined bounty');
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to join competition';
reportError(err, { context: 'bounties-joinCompetition', bountyId });
toast.error(message);
},
});
}

export function useEditApplication(bountyId: string, applicationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Pick<BountyApplication, 'message'>>) => {
const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications/${encodeURIComponent(applicationId)}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
return data.application;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] });
toast.success('Application updated');
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to update application';
reportError(err, { context: 'bounties-editApplication', bountyId, applicationId });
toast.error(message);
},
});
}

export function useWithdrawApplication(bountyId: string, applicationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await request(`/api/bounties/${encodeURIComponent(bountyId)}/applications/${encodeURIComponent(applicationId)}`, {
method: 'DELETE',
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] });
toast.success('Application withdrawn');
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to withdraw application';
reportError(err, { context: 'bounties-withdrawApplication', bountyId, applicationId });
toast.error(message);
},
});
}

async function runEscrowOp<T>(payload: Record<string, unknown>): Promise<T> {
const data = await request<T>('/api/escrow/ops', {
method: 'POST',
body: JSON.stringify({ scope: 'participant', mode: 'MANAGED', ...payload }),
});
return data;
}

export function useSubmitBounty(bountyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: { description: string; links?: Array<{ type?: string; url: string }> }) => {
const description = payload?.description;
const links = payload?.links;
if (!description) {
throw new Error('Description is required to submit bounty work');
}
const result = await runEscrowOp<{ submission: { id: string; status: string } }>({
bountyId,
description,
links,
});
if (!result || !('submission' in result)) {
throw new Error('Escrow operation did not return a submission');
}
return result as { submission: { id: string; status: string } };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'detail', bountyId] });
toast.success(`Submission recorded${result?.submission?.id ? ': ' + result.submission.id : ''}`);
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to submit bounty work';
reportError(err, { context: 'bounties-submitBounty', bountyId });
toast.error(message);
},
});
}

export function useWithdrawSubmission(bountyId: string, submissionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await request(`/api/bounties/${encodeURIComponent(bountyId)}/submissions/${encodeURIComponent(submissionId)}`, {
method: 'DELETE',
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'detail', bountyId] });
toast.success('Submission withdrawn');
},
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to withdraw submission';
reportError(err, { context: 'bounties-withdrawSubmission', bountyId, submissionId });
toast.error(message);
},
});
}

type ParticipantSubmission = { id: string; bountyId: string; status: string };

type ParticipantActivityItem =
| { kind: 'application'; data: BountyApplication }
| { kind: 'submission'; data: ParticipantSubmission };

export function useMyBountyActivity(bountyId: string) {
const applications = useMyBountyApplications(bountyId);
const submissions = useQuery({
queryKey: [...BOUNDED_PREFIX, 'my-submissions', bountyId],
queryFn: async (): Promise<ParticipantSubmission[]> => {
const data = await request<{ submissions: ParticipantSubmission[] }>(`/api/bounties/${encodeURIComponent(bountyId)}/submissions/me`);
return data.submissions ?? [];
},
enabled: !!bountyId,
staleTime: 20_000,
});

return useQuery({
queryKey: [...BOUNDED_PREFIX, 'my-activity', bountyId],
queryFn: async (): Promise<ParticipantActivityItem[]> => {
const apps = applications.data ?? [];
const subs = submissions.data ?? [];
return [
...apps.map<ParticipantActivityItem>(application => ({ kind: 'application', data: application })),
...subs.map<ParticipantActivityItem>(submission => ({ kind: 'submission', data: submission })),
];
},
enabled: !!bountyId && applications.isFetched && submissions.isFetched,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
70 changes: 70 additions & 0 deletions tests/hooks/bounties/use-bounty-escrow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import type { ReactNode } from 'react';
import {
useBountiesList,
useBounty,
useMyBountyApplications,
useApplyToBounty,
useJoinCompetition,
useEditApplication,
useWithdrawApplication,
useSubmitBounty,
useWithdrawSubmission,
useMyBountyActivity,
} from '@/features/bounties/hooks/use-bounty-escrow';

function wrap(hook: () => unknown) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderHook(() => hook(), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
});
}

describe('use-bounty-escrow', () => {
it('useBountiesList fetches bounties', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ bounties: [{ id: 'b1', title: 'T', description: '', reward: '1', status: 'open', createdAt: '2026-01-01T00:00:00Z' }], pagination: { total: 1 } }),
} as Response),
);

const { result } = wrap(() => useBountiesList());
await waitFor(() => result.current.isSuccess);
expect(result.current.data?.bounties).toHaveLength(1);
});

it('useSubmitBounty forwards payload to escrow op', async () => {
const captured: Array<{ url?: string; body?: BodyInit | null }> = [];
global.fetch = vi.fn((url: string, init?: RequestInit) => {
captured.push({ url, body: init?.body });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ submission: { id: 'sub_1', status: 'ok' } }),
} as Response);
});

const { result } = wrap(() => useSubmitBounty('bounty-1'));

await act(async () => {
await result.current.mutateAsync({ description: 'my submission payload' });
});

expect(
captured.some(
(c): c is { url: string; body?: BodyInit | null } =>
typeof c.url === 'string' &&
c.url.includes('/api/escrow/ops') &&
typeof c.body === 'string' &&
c.body.includes('bounty-1') &&
c.body.includes('my submission payload'),
),
).toBe(true);
});
});