From 8bbb6a45c8fc5f2c22c2a49d38efecd225952540 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 22:33:23 +0100 Subject: [PATCH] feat(bounty): participant data layer (escrow client + hooks) Add the builder/participant data layer to features/bounties, mirroring the hackathon participant scope and reusing the shared escrow runner. The visible builder UI (marketplace, detail, apply/submit/withdraw, my-bounties) consumes this. - participant-escrow-client: on-chain apply/submit/withdraw-application/ withdraw-submission/contribute + participant op poll + submit-signed (the no-org-prefix /api/bounties/{id}/escrow/* routes) - participant-client: public list/detail, v2 application records (apply/edit/withdraw/me), competition join/leave - use-participant hooks: useBountiesList/useBounty/useMyBountyApplication, useApply/Edit/WithdrawApplication, useJoin/LeaveCompetition, and escrow-op hooks (useSubmitBounty/useWithdrawSubmission/useContributeToBounty/ useApplyToBountyEscrow/useWithdrawApplicationEscrow) driven through the runner, default MANAGED - use-escrow: EscrowOpScope is now organizer | participant; the runner routes op-poll/submit-signed to the participant URLs - keys/types/index: participant query keys, type aliases, public exports MyBountyApplication is hand-typed until boundless-nestjs #331 lands and codegen runs; useMyBountyActivity is deferred until the participant dashboard (#332). Closes #621 --- features/bounties/api/keys.ts | 9 + features/bounties/api/participant-client.ts | 120 +++++++++ .../bounties/api/participant-escrow-client.ts | 107 ++++++++ features/bounties/api/use-escrow.ts | 45 ++-- features/bounties/api/use-participant.ts | 236 ++++++++++++++++++ features/bounties/index.ts | 60 +++++ features/bounties/types.ts | 55 ++++ 7 files changed, 612 insertions(+), 20 deletions(-) create mode 100644 features/bounties/api/participant-client.ts create mode 100644 features/bounties/api/participant-escrow-client.ts create mode 100644 features/bounties/api/use-participant.ts diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts index c0ca5a54..192ec8e3 100644 --- a/features/bounties/api/keys.ts +++ b/features/bounties/api/keys.ts @@ -10,4 +10,13 @@ export const bountyKeys = { [...bountyKeys.all, 'draft', organizationId, id] as const, escrowOp: (scope: string, opRowId: string) => [...bountyKeys.all, 'escrow-op', scope, opRowId] as const, + + // Builder / participant reads. + list: (params: Record = {}) => + [...bountyKeys.all, 'list', params] as const, + detail: (bountyId: string) => + [...bountyKeys.all, 'detail', bountyId] as const, + myApplication: (bountyId: string) => + [...bountyKeys.all, 'my-application', bountyId] as const, + myActivity: () => [...bountyKeys.all, 'my-activity'] as const, }; diff --git a/features/bounties/api/participant-client.ts b/features/bounties/api/participant-client.ts new file mode 100644 index 00000000..0e4c08d4 --- /dev/null +++ b/features/bounties/api/participant-client.ts @@ -0,0 +1,120 @@ +/** + * Builder/participant bounty REST client: the public marketplace reads and the + * v2 application-record surface (off-chain proposal carrier for the application + * entry types), plus open-competition join/leave. + * + * Several backend handlers still return the raw entity without an `@ApiOkResponse` + * (so the generated schema types them as `never`). Those are cast to the + * hand-typed `MyBountyApplication` until boundless-nestjs #331 lands and codegen + * picks up the real DTO. + */ +import { apiClient, unwrapData } from '@/lib/api'; + +import type { + BountyPublic, + BountyPublicList, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + JoinCompetitionRequest, + MyBountyApplication, +} from '../types'; + +export interface BountiesListParams { + organizationId?: string; + status?: string; + search?: string; + page?: number; + limit?: number; +} + +// ── Public marketplace ──────────────────────────────────────────────────────── + +/** Paginated public bounty list (the marketplace). */ +export const listBounties = async ( + params: BountiesListParams = {} +): Promise => + unwrapData( + await apiClient.GET('/api/bounties', { params: { query: params } }) + ); + +/** A single public bounty (the detail page). */ +export const getBounty = async (bountyId: string): Promise => + unwrapData( + await apiClient.GET('/api/bounties/{id}', { + params: { path: { id: bountyId } }, + }) + ); + +// ── v2 application records (application entry types) ────────────────────────── + +/** The caller's own application for a bounty, or null when they have not applied. */ +export const getMyBountyApplication = async ( + bountyId: string +): Promise => + (unwrapData( + await apiClient.GET('/api/bounties/{bountyId}/v2/applications/me', { + params: { path: { bountyId } }, + }) + ) as unknown as MyBountyApplication | null) ?? null; + +/** Submit an application (light / full proposal) for an application-mode bounty. */ +export const applyToBounty = async ( + bountyId: string, + body: CreateBountyApplicationRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{bountyId}/v2/applications', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Edit a SUBMITTED application (before shortlist). */ +export const editBountyApplication = async ( + bountyId: string, + appId: string, + body: EditBountyApplicationRequest +): Promise => + unwrapData( + await apiClient.PATCH('/api/bounties/{bountyId}/v2/applications/{appId}', { + params: { path: { bountyId, appId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Withdraw a SUBMITTED application (the application record; status -> WITHDRAWN). */ +export const withdrawBountyApplication = async ( + bountyId: string, + appId: string +): Promise => + unwrapData( + await apiClient.DELETE('/api/bounties/{bountyId}/v2/applications/{appId}', { + params: { path: { bountyId, appId } }, + }) + ) as unknown as MyBountyApplication; + +// ── Open competition join / leave ───────────────────────────────────────────── + +/** Join an open competition directly (no application step). */ +export const joinCompetition = async ( + bountyId: string, + body: JoinCompetitionRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{bountyId}/v2/competition/join', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Leave an open competition the caller joined. */ +export const leaveCompetition = async ( + bountyId: string, + body: JoinCompetitionRequest +): Promise => + unwrapData( + await apiClient.DELETE('/api/bounties/{bountyId}/v2/competition/join', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; diff --git a/features/bounties/api/participant-escrow-client.ts b/features/bounties/api/participant-escrow-client.ts new file mode 100644 index 00000000..44fc9306 --- /dev/null +++ b/features/bounties/api/participant-escrow-client.ts @@ -0,0 +1,107 @@ +/** + * Builder/participant bounty escrow client (boundless-events contract), on the + * typed openapi-fetch client. Counterpart to the organizer `escrow-client.ts`: + * these are the participant-scoped, no-org-prefix routes (`/api/bounties/{id}/ + * escrow/...`) the builder lifecycle drives through the shared escrow runner. + * + * Like the organizer ops, each builds an EscrowOp: + * MANAGED : backend signs with the caller's platform-held wallet + submits; + * the op returns in PENDING_CONFIRM (the webapp only polls). + * EXTERNAL : backend returns `unsignedXdr`; the webapp signs with a connected + * wallet and POSTs to submit-signed, then polls. + */ +import { apiClient, unwrapData } from '@/lib/api'; + +import type { + ApplyBountyRequest, + BountyEscrowOpResponse, + ContributeBountyRequest, + SubmitBountyRequest, + SubmitSignedXdrRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, +} from '../types'; + +/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; charges application credits). */ +export const applyToBountyEscrow = async ( + bountyId: string, + body: ApplyBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/apply', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Withdraw a bounty application (refunds half the application credits). */ +export const withdrawBountyApplicationEscrow = async ( + bountyId: string, + body: WithdrawApplicationRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/withdraw-application', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Anchor a work submission on-chain (SUBMIT; requires an active application). */ +export const submitBountyWorkEscrow = async ( + bountyId: string, + body: SubmitBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/submit', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Withdraw a submission anchor. */ +export const withdrawBountySubmissionEscrow = async ( + bountyId: string, + body: WithdrawSubmissionRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/withdraw-submission', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Contribute funds to a bounty's escrow pool (ADD_FUNDS; 10 USDC minimum). */ +export const contributeToBountyEscrow = async ( + bountyId: string, + body: ContributeBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/contribute', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Submit a wallet-signed XDR for a participant op (EXTERNAL path). */ +export const submitSignedParticipantBounty = async ( + bountyId: string, + opRowId: string, + body: SubmitSignedXdrRequest +): Promise => + unwrapData( + await apiClient.POST( + '/api/bounties/{id}/escrow/ops/{opRowId}/submit-signed', + { params: { path: { id: bountyId, opRowId } }, body } + ) + ); + +/** Poll the current state of a participant escrow op. */ +export const getParticipantBountyOp = async ( + bountyId: string, + opRowId: string +): Promise => + unwrapData( + await apiClient.GET('/api/bounties/{id}/escrow/ops/{opRowId}', { + params: { path: { id: bountyId, opRowId } }, + }) + ); diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts index ad42f4c3..98067fb7 100644 --- a/features/bounties/api/use-escrow.ts +++ b/features/bounties/api/use-escrow.ts @@ -32,6 +32,10 @@ import { selectBountyWinners, submitSignedBountyEscrow, } from './escrow-client'; +import { + getParticipantBountyOp, + submitSignedParticipantBounty, +} from './participant-escrow-client'; import { isTerminalEscrowStatus, type BountyEscrowOpResponse, @@ -41,19 +45,19 @@ import { type SelectBountyWinnersRequest, } from '../types'; -// ─── Scope: organizer (org + bounty) ───────────────────────────────────────── +// ─── Scope: organizer (org + bounty) or participant (bounty-only) ───────────── -export type EscrowOpScope = { - kind: 'organizer'; - organizationId: string; - bountyId: string; -}; +export type EscrowOpScope = + | { kind: 'organizer'; organizationId: string; bountyId: string } + | { kind: 'participant'; bountyId: string }; function getOpForScope( scope: EscrowOpScope, opRowId: string ): Promise { - return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId); + return scope.kind === 'participant' + ? getParticipantBountyOp(scope.bountyId, opRowId) + : getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId); } function submitSignedForScope( @@ -61,22 +65,23 @@ function submitSignedForScope( opRowId: string, signedXdr: string ): Promise { - return submitSignedBountyEscrow( - scope.organizationId, - scope.bountyId, - opRowId, - { signedXdr } - ); + return scope.kind === 'participant' + ? submitSignedParticipantBounty(scope.bountyId, opRowId, { signedXdr }) + : submitSignedBountyEscrow(scope.organizationId, scope.bountyId, opRowId, { + signedXdr, + }); } function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey { - return [ - 'bounty-escrow-op', - 'organizer', - scope.organizationId, - scope.bountyId, - opRowId, - ]; + return scope.kind === 'participant' + ? ['bounty-escrow-op', 'participant', scope.bountyId, opRowId] + : [ + 'bounty-escrow-op', + 'organizer', + scope.organizationId, + scope.bountyId, + opRowId, + ]; } // ─── 1. Polling primitive ──────────────────────────────────────────────────── diff --git a/features/bounties/api/use-participant.ts b/features/bounties/api/use-participant.ts new file mode 100644 index 00000000..f3ebcad6 --- /dev/null +++ b/features/bounties/api/use-participant.ts @@ -0,0 +1,236 @@ +'use client'; + +/** + * React Query hooks for the bounty builder/participant lifecycle (boundless #621). + * + * reads — useBountiesList / useBounty (public), useMyBountyApplication. + * applications — useApplyToBounty / useEditApplication / useWithdrawApplication + * / useJoinCompetition / useLeaveCompetition (v2 records). + * escrow ops — useSubmitBounty / useWithdrawSubmission / useContributeToBounty + * / useApplyToBountyEscrow / useWithdrawApplicationEscrow, each + * driven through the shared {@link useEscrowOpRunner} on the + * participant scope. Funding defaults to MANAGED (the backend + * signs + submits with the caller's platform-held wallet); pass + * a `signXdr` to take the EXTERNAL connected-wallet path. + * + * `useMyBountyActivity` (cross-bounty applications + submissions) waits on the + * backend participant dashboard (boundless-nestjs #332); it is intentionally not + * implemented here yet. + */ +import { useCallback } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useEscrowOpRunner, type SignXdrFn } from './use-escrow'; +import { bountyKeys } from './keys'; +import { + applyToBountyEscrow, + contributeToBountyEscrow, + submitBountyWorkEscrow, + withdrawBountyApplicationEscrow, + withdrawBountySubmissionEscrow, +} from './participant-escrow-client'; +import { + applyToBounty, + editBountyApplication, + getBounty, + getMyBountyApplication, + joinCompetition, + leaveCompetition, + listBounties, + withdrawBountyApplication, + type BountiesListParams, +} from './participant-client'; +import type { + ApplyBountyRequest, + ContributeBountyRequest, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + FundingMode, + JoinCompetitionRequest, + SubmitBountyRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, +} from '../types'; + +// ── Reads ───────────────────────────────────────────────────────────────────── + +/** Paginated public marketplace list. */ +export function useBountiesList(params: BountiesListParams = {}) { + return useQuery({ + queryKey: bountyKeys.list(params as Record), + queryFn: () => listBounties(params), + }); +} + +/** A single public bounty (detail page). */ +export function useBounty(bountyId: string | undefined) { + return useQuery({ + queryKey: bountyKeys.detail(bountyId ?? ''), + queryFn: () => getBounty(bountyId as string), + enabled: !!bountyId, + }); +} + +/** The caller's own application for a bounty (drives the detail CTA). */ +export function useMyBountyApplication(bountyId: string | undefined) { + return useQuery({ + queryKey: bountyKeys.myApplication(bountyId ?? ''), + queryFn: () => getMyBountyApplication(bountyId as string), + enabled: !!bountyId, + }); +} + +// ── Application records (v2) ────────────────────────────────────────────────── + +/** Submit an application (light/full proposal) for an application-mode bounty. */ +export function useApplyToBounty(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateBountyApplicationRequest) => + applyToBounty(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Edit a SUBMITTED application (before shortlist). */ +export function useEditApplication(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (vars: { appId: string; body: EditBountyApplicationRequest }) => + editBountyApplication(bountyId, vars.appId, vars.body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Withdraw a SUBMITTED application record (status -> WITHDRAWN). */ +export function useWithdrawApplication(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (appId: string) => withdrawBountyApplication(bountyId, appId), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Join an open competition directly (no application step). */ +export function useJoinCompetition(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: JoinCompetitionRequest) => + joinCompetition(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Leave an open competition the caller joined. */ +export function useLeaveCompetition(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: JoinCompetitionRequest) => + leaveCompetition(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +// ── Escrow ops (participant scope, default MANAGED) ─────────────────────────── + +/** A participant op body with funding mode optional (defaults to MANAGED). */ +type WithManagedDefault = Omit< + T, + 'fundingMode' +> & { fundingMode?: FundingMode }; + +const withManaged = ( + body: WithManagedDefault +): T => ({ fundingMode: 'MANAGED', ...body }) as T; + +interface ParticipantOpOptions { + /** Sign the unsigned XDR for the EXTERNAL path. Omit for MANAGED. */ + signXdr?: SignXdrFn; +} + +/** + * Build a participant escrow-op hook: a runner on the participant scope plus a + * `run(body)` that starts the op (funding defaults to MANAGED). Returns the full + * runner surface (phase/status/error/txHash) for the calling UI. + */ +function useParticipantOp( + bountyId: string, + start: (id: string, body: T) => ReturnType, + options: ParticipantOpOptions = {} +) { + const runner = useEscrowOpRunner( + { kind: 'participant', bountyId }, + options.signXdr ? { signXdr: options.signXdr } : undefined + ); + const run = useCallback( + (body: WithManagedDefault) => + runner.run(() => start(bountyId, withManaged(body))), + [runner, bountyId, start] + ); + return { ...runner, run }; +} + +/** Anchor a work submission on-chain (SUBMIT). */ +export function useSubmitBounty( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + submitBountyWorkEscrow, + options + ); +} + +/** Withdraw a submission anchor. */ +export function useWithdrawSubmission( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + withdrawBountySubmissionEscrow, + options + ); +} + +/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; open modes). */ +export function useApplyToBountyEscrow( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + applyToBountyEscrow, + options + ); +} + +/** Withdraw an on-chain application (open modes). */ +export function useWithdrawApplicationEscrow( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + withdrawBountyApplicationEscrow, + options + ); +} + +/** Contribute funds to a bounty's escrow pool (ADD_FUNDS). */ +export function useContributeToBounty( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + contributeToBountyEscrow, + options + ); +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts index ce57379e..2273615b 100644 --- a/features/bounties/index.ts +++ b/features/bounties/index.ts @@ -86,3 +86,63 @@ export type { UseEscrowOpRunnerOptions, EscrowOpRunner, } from './api/use-escrow'; + +// ── Builder / participant data layer (#621) ────────────────────────────────── + +// Participant types (aliased from the generated schema; MyBountyApplication is +// hand-typed until boundless-nestjs #331 lands and codegen runs). +export type { + BountyPublic, + BountyPublicList, + MyBountyApplication, + BountyApplicationStatus, + ApplyBountyRequest, + SubmitBountyRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, + ContributeBountyRequest, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + JoinCompetitionRequest, +} from './types'; + +// Participant REST client (public reads + v2 application records + competition). +export { + listBounties, + getBounty, + getMyBountyApplication, + applyToBounty, + editBountyApplication, + withdrawBountyApplication, + joinCompetition, + leaveCompetition, +} from './api/participant-client'; +export type { BountiesListParams } from './api/participant-client'; + +// Participant escrow client (on-chain apply/submit/withdraw/contribute). +export { + applyToBountyEscrow, + withdrawBountyApplicationEscrow, + submitBountyWorkEscrow, + withdrawBountySubmissionEscrow, + contributeToBountyEscrow, + getParticipantBountyOp, + submitSignedParticipantBounty, +} from './api/participant-escrow-client'; + +// Participant hooks (React Query). +export { + useBountiesList, + useBounty, + useMyBountyApplication, + useApplyToBounty, + useEditApplication, + useWithdrawApplication, + useJoinCompetition, + useLeaveCompetition, + useSubmitBounty, + useWithdrawSubmission, + useApplyToBountyEscrow, + useWithdrawApplicationEscrow, + useContributeToBounty, +} from './api/use-participant'; diff --git a/features/bounties/types.ts b/features/bounties/types.ts index 132d0aa2..0727334c 100644 --- a/features/bounties/types.ts +++ b/features/bounties/types.ts @@ -74,3 +74,58 @@ export const TERMINAL_ESCROW_STATUSES: readonly EscrowOpStatus[] = [ export const isTerminalEscrowStatus = (status: EscrowOpStatus): boolean => TERMINAL_ESCROW_STATUSES.includes(status); + +// ── Builder / participant ───────────────────────────────────────────────────── + +/** Public marketplace bounty (list row + detail share this shape). */ +export type BountyPublic = Schemas['BountyPublicDto']; +/** Paginated marketplace list response. */ +export type BountyPublicList = Schemas['BountyPublicListDto']; + +// Participant escrow op request bodies (on-chain apply/submit/withdraw/contribute). +export type ApplyBountyRequest = Schemas['ApplyBountyDto']; +export type SubmitBountyRequest = Schemas['SubmitBountyDto']; +export type WithdrawApplicationRequest = Schemas['WithdrawApplicationDto']; +export type WithdrawSubmissionRequest = Schemas['WithdrawSubmissionDto']; +export type ContributeBountyRequest = Schemas['ContributeBountyDto']; + +// v2 application records (off-chain proposal carrier for application modes). +export type CreateBountyApplicationRequest = + Schemas['CreateBountyApplicationDto']; +export type EditBountyApplicationRequest = Schemas['EditBountyApplicationDto']; +export type JoinCompetitionRequest = Schemas['JoinCompetitionDto']; + +/** + * The caller's own application for a bounty (GET …/v2/applications/me). + * + * The backend `BountyApplicationResponseDto` (boundless-nestjs #331) is not yet + * in the generated schema, so this is hand-typed to its shape. Replace with + * `Schemas['BountyApplicationResponseDto']` after that lands and codegen runs. + */ +export type BountyApplicationStatus = + | 'SUBMITTED' + | 'SHORTLISTED' + | 'SELECTED' + | 'DECLINED' + | 'WITHDRAWN'; + +export interface MyBountyApplication { + id: string; + bountyId: string; + applicantAddress: string; + status: string; + applicationStatus: BountyApplicationStatus | null; + proposalShort: string | null; + proposalFull: string | null; + portfolioLinks: string[]; + estimatedDays: number | null; + qualifications: string | null; + videoIntroUrl: string | null; + declineReason: string | null; + shortlistedAt: string | null; + selectedAt: string | null; + declinedAt: string | null; + withdrawnAt: string | null; + createdAt: string; + updatedAt: string; +}