Skip to content
Merged
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
74 changes: 74 additions & 0 deletions features/bounties/api/draft-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Non-hook bounty draft client calls on the typed openapi-fetch client. These
* back the React Query hooks in use-draft.ts and are available for imperative
* callers (e.g. a create-then-update sequence in the wizard machinery, #598).
* The response envelope is unwrapped by the apiClient middleware.
*/
import { apiClient, unwrapData } from '@/lib/api';

import type { BountyDraft, UpdateBountyDraftBody } from '../types';

/** Create an empty bounty draft in `draft` status. */
export const createBountyDraft = async (
organizationId: string
): Promise<BountyDraft> =>
unwrapData(
await apiClient.POST('/api/organizations/{organizationId}/bounties/draft', {
params: { path: { organizationId } },
})
);

/** Apply one or more wizard sections in a single PATCH. */
export const updateBountyDraft = async (
organizationId: string,
id: string,
body: UpdateBountyDraftBody
): Promise<BountyDraft> =>
unwrapData(
await apiClient.PATCH(
'/api/organizations/{organizationId}/bounties/draft/{id}',
{ params: { path: { organizationId, id } }, body }
)
);

/** Fetch a single draft for resume. */
export const getBountyDraft = async (
organizationId: string,
id: string
): Promise<BountyDraft> =>
unwrapData(
await apiClient.GET(
'/api/organizations/{organizationId}/bounties/draft/{id}',
{ params: { path: { organizationId, id } } }
)
);

/** List an organization's drafts (draft + draft_awaiting_funding). */
export const listBountyDrafts = async (
organizationId: string
): Promise<BountyDraft[]> =>
unwrapData(
await apiClient.GET('/api/organizations/{organizationId}/bounties/drafts', {
params: { path: { organizationId } },
})
);

export interface DeleteBountyDraftResult {
success: true;
message: string;
data: null;
}

/** Delete a draft. 204 No Content on success. */
export const deleteBountyDraft = async (
organizationId: string,
id: string
): Promise<DeleteBountyDraftResult> => {
await unwrapData(
await apiClient.DELETE(
'/api/organizations/{organizationId}/bounties/draft/{id}',
{ params: { path: { organizationId, id } } }
)
);
return { success: true, message: 'Deleted successfully', data: null };
};
93 changes: 93 additions & 0 deletions features/bounties/api/escrow-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Bounty escrow API client (boundless-events contract), on the typed
* openapi-fetch client. Request/response shapes come from the backend-generated
* schema; the response envelope is unwrapped by the apiClient middleware.
*
* Every action builds an EscrowOp the webapp drives through its lifecycle:
* PENDING_BUILD -> PENDING_SIGN -> PENDING_SUBMIT -> PENDING_CONFIRM
* -> COMPLETED | FAILED | CANCELLED
*
* 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.
*
* These are the organizer (org-scoped) operations the Configure / publish flow
* needs. Builder-facing participant escrow (apply / submit / contribute) is out
* of scope for the Configure epic.
*/
import { apiClient, unwrapData } from '@/lib/api';

import type {
BountyEscrowOpResponse,
CancelBountyEscrowRequest,
PublishBountyEscrowRequest,
SelectBountyWinnersRequest,
SubmitSignedXdrRequest,
} from '../types';

/** Publish a bounty draft to the events contract (CREATE_EVENT). */
export const publishBountyEscrow = async (
organizationId: string,
bountyId: string,
body: PublishBountyEscrowRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST(
'/api/organizations/{organizationId}/bounties/{id}/escrow/publish',
{ params: { path: { organizationId, id: bountyId } }, body }
)
);

/** Cancel an active bounty and refund contributors + owner. */
export const cancelBountyEscrow = async (
organizationId: string,
bountyId: string,
body: CancelBountyEscrowRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST(
'/api/organizations/{organizationId}/bounties/{id}/escrow/cancel',
{ params: { path: { organizationId, id: bountyId } }, body }
)
);

/** Declare winners and trigger payout (SELECT_WINNERS). */
export const selectBountyWinners = async (
organizationId: string,
bountyId: string,
body: SelectBountyWinnersRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST(
'/api/organizations/{organizationId}/bounties/{id}/escrow/select-winners',
{ params: { path: { organizationId, id: bountyId } }, body }
)
);

/** Submit a wallet-signed XDR for an organizer op (EXTERNAL path). */
export const submitSignedBountyEscrow = async (
organizationId: string,
bountyId: string,
opRowId: string,
body: SubmitSignedXdrRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST(
'/api/organizations/{organizationId}/bounties/{id}/escrow/ops/{opRowId}/submit-signed',
{ params: { path: { organizationId, id: bountyId, opRowId } }, body }
)
);

/** Poll the current state of an organizer escrow op. */
export const getBountyEscrowOp = async (
organizationId: string,
bountyId: string,
opRowId: string
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.GET(
'/api/organizations/{organizationId}/bounties/{id}/escrow/ops/{opRowId}',
{ params: { path: { organizationId, id: bountyId, opRowId } } }
)
);
13 changes: 13 additions & 0 deletions features/bounties/api/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* React Query key factory for the bounties feature. Co-locating the keys keeps
* the hooks and any imperative `queryClient.invalidateQueries` calls in sync.
*/
export const bountyKeys = {
all: ['bounties'] as const,
drafts: (organizationId: string) =>
[...bountyKeys.all, 'drafts', organizationId] as const,
draft: (organizationId: string, id: string) =>
[...bountyKeys.all, 'draft', organizationId, id] as const,
escrowOp: (scope: string, opRowId: string) =>
[...bountyKeys.all, 'escrow-op', scope, opRowId] as const,
};
94 changes: 94 additions & 0 deletions features/bounties/api/use-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import {
createBountyDraft,
deleteBountyDraft,
getBountyDraft,
listBountyDrafts,
updateBountyDraft,
} from './draft-client';
import type { BountyDraft, UpdateBountyDraftBody } from '../types';
import { bountyKeys } from './keys';

/** Fetch a single draft. Disabled until both ids are present. */
export function useDraft(
organizationId: string,
id: string | null | undefined
) {
return useQuery({
queryKey:
organizationId && id
? bountyKeys.draft(organizationId, id)
: [...bountyKeys.all, 'draft', 'idle'],
enabled: Boolean(organizationId && id),
queryFn: (): Promise<BountyDraft> =>
getBountyDraft(organizationId, id as string),
});
}

/** List an organization's drafts (draft + draft_awaiting_funding). */
export function useDraftList(organizationId: string) {
return useQuery({
queryKey: bountyKeys.drafts(organizationId),
enabled: Boolean(organizationId),
queryFn: (): Promise<BountyDraft[]> => listBountyDrafts(organizationId),
});
}

/** Create an empty draft. */
export function useCreateDraft(organizationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (): Promise<BountyDraft> => createBountyDraft(organizationId),
onSuccess: draft => {
queryClient.setQueryData(
bountyKeys.draft(organizationId, draft.id),
draft
);
queryClient.invalidateQueries({
queryKey: bountyKeys.drafts(organizationId),
});
},
});
}

/**
* Update one or more draft sections in a single flat PATCH. Send one section
* for a per-step "Continue", or several for "Save draft". The draft id is passed
* per-call (so a create-then-update sequence can use the fresh id with no stale
* closure). Seeds the draft cache with the server copy so reads stay in sync.
*/
export function useUpdateDraft(organizationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
body,
}: {
id: string;
body: UpdateBountyDraftBody;
}): Promise<BountyDraft> => updateBountyDraft(organizationId, id, body),
onSuccess: draft => {
queryClient.setQueryData(
bountyKeys.draft(organizationId, draft.id),
draft
);
},
});
}

/** Delete a draft. */
export function useDeleteDraft(organizationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string): Promise<void> => {
return deleteBountyDraft(organizationId, id).then(() => undefined);
},
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: bountyKeys.drafts(organizationId),
}),
});
}
Loading