diff --git a/features/bounties/api/draft-client.ts b/features/bounties/api/draft-client.ts new file mode 100644 index 00000000..479ffc3e --- /dev/null +++ b/features/bounties/api/draft-client.ts @@ -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 => + 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 => + 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 => + 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 => + 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 => { + await unwrapData( + await apiClient.DELETE( + '/api/organizations/{organizationId}/bounties/draft/{id}', + { params: { path: { organizationId, id } } } + ) + ); + return { success: true, message: 'Deleted successfully', data: null }; +}; diff --git a/features/bounties/api/escrow-client.ts b/features/bounties/api/escrow-client.ts new file mode 100644 index 00000000..f5c1829a --- /dev/null +++ b/features/bounties/api/escrow-client.ts @@ -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 => + 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 => + 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 => + 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 => + 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 => + unwrapData( + await apiClient.GET( + '/api/organizations/{organizationId}/bounties/{id}/escrow/ops/{opRowId}', + { params: { path: { organizationId, id: bountyId, opRowId } } } + ) + ); diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts new file mode 100644 index 00000000..c0ca5a54 --- /dev/null +++ b/features/bounties/api/keys.ts @@ -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, +}; diff --git a/features/bounties/api/use-draft.ts b/features/bounties/api/use-draft.ts new file mode 100644 index 00000000..1e0a0519 --- /dev/null +++ b/features/bounties/api/use-draft.ts @@ -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 => + 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 => listBountyDrafts(organizationId), + }); +} + +/** Create an empty draft. */ +export function useCreateDraft(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (): Promise => 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 => 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 => { + return deleteBountyDraft(organizationId, id).then(() => undefined); + }, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: bountyKeys.drafts(organizationId), + }), + }); +} diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts new file mode 100644 index 00000000..ad42f4c3 --- /dev/null +++ b/features/bounties/api/use-escrow.ts @@ -0,0 +1,321 @@ +'use client'; + +/** + * React Query hooks for the bounty escrow flow (boundless-events). + * + * Mirrors the hackathon escrow machinery (features/hackathons/api/use-escrow): + * 1. useEscrowOp — polling primitive; watches an op to a terminal state. + * 2. mutation wrappers — usePublishBountyEscrow / useSelectBountyWinners / + * useCancelBountyEscrow. + * 3. useEscrowOpRunner — orchestrates start -> (sign+submit for EXTERNAL) -> + * poll-to-terminal, invalidating bounty caches on + * COMPLETED. + * + * Only the scope shape (org + bounty) and the publish wrapper differ from the + * hackathon version. MANAGED ops come back already in PENDING_CONFIRM (backend + * signed+submitted), so the runner just polls. EXTERNAL ops come back in + * PENDING_SIGN with an `unsignedXdr`; the runner signs via the injected + * `signXdr` and posts it to submit-signed before polling. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useMutation, + useQuery, + useQueryClient, + type QueryKey, +} from '@tanstack/react-query'; + +import { + cancelBountyEscrow, + getBountyEscrowOp, + publishBountyEscrow, + selectBountyWinners, + submitSignedBountyEscrow, +} from './escrow-client'; +import { + isTerminalEscrowStatus, + type BountyEscrowOpResponse, + type CancelBountyEscrowRequest, + type EscrowOpStatus, + type PublishBountyEscrowRequest, + type SelectBountyWinnersRequest, +} from '../types'; + +// ─── Scope: organizer (org + bounty) ───────────────────────────────────────── + +export type EscrowOpScope = { + kind: 'organizer'; + organizationId: string; + bountyId: string; +}; + +function getOpForScope( + scope: EscrowOpScope, + opRowId: string +): Promise { + return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId); +} + +function submitSignedForScope( + scope: EscrowOpScope, + opRowId: string, + signedXdr: string +): Promise { + return submitSignedBountyEscrow( + scope.organizationId, + scope.bountyId, + opRowId, + { signedXdr } + ); +} + +function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey { + return [ + 'bounty-escrow-op', + 'organizer', + scope.organizationId, + scope.bountyId, + opRowId, + ]; +} + +// ─── 1. Polling primitive ──────────────────────────────────────────────────── + +export interface UseEscrowOpOptions { + enabled?: boolean; + /** Poll cadence while the op is non-terminal. Default 2500ms. */ + pollMs?: number; +} + +/** + * Poll an escrow op until it reaches a terminal state. Returns the standard + * react-query result; `data` is the latest BountyEscrowOpResponse. + */ +export function useEscrowOp( + scope: EscrowOpScope, + opRowId: string | null | undefined, + options: UseEscrowOpOptions = {} +) { + const pollMs = options.pollMs ?? 2500; + return useQuery({ + queryKey: opRowId + ? escrowOpKey(scope, opRowId) + : ['bounty-escrow-op', 'idle'], + queryFn: () => getOpForScope(scope, opRowId as string), + enabled: !!opRowId && (options.enabled ?? true), + refetchInterval: query => { + const data = query.state.data as BountyEscrowOpResponse | undefined; + if (!data) return pollMs; + return isTerminalEscrowStatus(data.status) ? false : pollMs; + }, + // Pending ops change server-side; never treat a non-terminal row as fresh. + staleTime: 0, + }); +} + +// ─── 2. Mutation wrappers ──────────────────────────────────────────────────── + +export function usePublishBountyEscrow( + organizationId: string, + bountyId: string +) { + return useMutation({ + mutationFn: (body: PublishBountyEscrowRequest) => + publishBountyEscrow(organizationId, bountyId, body), + }); +} + +export function useSelectBountyWinners( + organizationId: string, + bountyId: string +) { + return useMutation({ + mutationFn: (body: SelectBountyWinnersRequest) => + selectBountyWinners(organizationId, bountyId, body), + }); +} + +export function useCancelBountyEscrow( + organizationId: string, + bountyId: string +) { + return useMutation({ + mutationFn: (body: CancelBountyEscrowRequest) => + cancelBountyEscrow(organizationId, bountyId, body), + }); +} + +// ─── 3. Orchestrating runner ───────────────────────────────────────────────── + +/** Sign an unsigned XDR for the EXTERNAL path. */ +export type SignXdrFn = ( + unsignedXdr: string, + signerHint?: string | null +) => Promise; + +export type EscrowRunPhase = + | 'idle' + | 'starting' + | 'signing' + | 'submitting' + | 'polling' + | 'completed' + | 'failed'; + +export interface UseEscrowOpRunnerOptions { + /** + * Signer for the EXTERNAL path. When the start op returns PENDING_SIGN with + * an unsignedXdr, the runner calls this then posts to submit-signed. Omit for + * MANAGED-only flows (the op is already PENDING_CONFIRM). + */ + signXdr?: SignXdrFn; + /** + * Query keys to invalidate when the op COMPLETES. Defaults to the whole + * `['bounties']` tree so a status transition renders. + */ + invalidateKeys?: QueryKey[]; + pollMs?: number; +} + +export interface EscrowOpRunner { + /** Kick off an op. `start` is the mutation call returning the built op. */ + run: ( + start: () => Promise + ) => Promise; + reset: () => void; + /** Latest op (polled if available, else the build result). */ + op: BountyEscrowOpResponse | null; + status: EscrowOpStatus | 'IDLE'; + phase: EscrowRunPhase; + /** True while building/signing/submitting/confirming. */ + isRunning: boolean; + isCompleted: boolean; + isFailed: boolean; + isTerminal: boolean; + /** Friendly error text (contract errorCode or thrown message). */ + error: string | null; + txHash: string | null; +} + +const DEFAULT_INVALIDATE: QueryKey[] = [['bounties']]; + +/** + * Drive a single escrow op from start to terminal state, handling the + * MANAGED-vs-EXTERNAL signing branch and cache invalidation on completion. + */ +export function useEscrowOpRunner( + scope: EscrowOpScope, + options: UseEscrowOpRunnerOptions = {} +): EscrowOpRunner { + const queryClient = useQueryClient(); + const [opRowId, setOpRowId] = useState(null); + const [startOp, setStartOp] = useState(null); + const [phase, setPhase] = useState('idle'); + const [error, setError] = useState(null); + // Guard so the terminal-side-effect (invalidate) fires once per op. + const settledRef = useRef(null); + + const { signXdr, pollMs } = options; + const invalidateKeys = options.invalidateKeys ?? DEFAULT_INVALIDATE; + + const poll = useEscrowOp(scope, opRowId, { enabled: !!opRowId, pollMs }); + + const op = poll.data ?? startOp; + const status: EscrowOpStatus | 'IDLE' = op?.status ?? 'IDLE'; + + // React to terminal transitions observed by the poller. + useEffect(() => { + if (!op || !opRowId) return; + if (!isTerminalEscrowStatus(op.status)) return; + if (settledRef.current === opRowId) return; + settledRef.current = opRowId; + + if (op.status === 'COMPLETED') { + setPhase('completed'); + invalidateKeys.forEach(key => + queryClient.invalidateQueries({ queryKey: key }) + ); + } else { + setPhase('failed'); + setError(prev => prev ?? op.errorCode ?? `Escrow op ${op.status}`); + } + // queryClient + invalidateKeys are stable enough; op.status is the trigger. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [op?.status, opRowId]); + + const reset = useCallback(() => { + setOpRowId(null); + setStartOp(null); + setPhase('idle'); + setError(null); + settledRef.current = null; + }, []); + + const run = useCallback( + async ( + start: () => Promise + ): Promise => { + setError(null); + settledRef.current = null; + setPhase('starting'); + try { + const built = await start(); + setStartOp(built); + setOpRowId(built.id); + + // EXTERNAL: the op needs a wallet signature before it can settle. + if (built.status === 'PENDING_SIGN' && built.unsignedXdr && signXdr) { + setPhase('signing'); + const signed = await signXdr(built.unsignedXdr, built.signerHint); + setPhase('submitting'); + const submitted = await submitSignedForScope(scope, built.id, signed); + setStartOp(submitted); + setPhase('polling'); + return submitted; + } + + if (built.status === 'PENDING_SIGN' && !signXdr) { + // External op with no signer available — can't progress. + setPhase('failed'); + setError( + 'This op needs a connected wallet signature, but no signer is ' + + 'configured. Use the managed wallet or connect a wallet.' + ); + return built; + } + + // MANAGED (already PENDING_CONFIRM) or terminal: let the poller finish. + setPhase('polling'); + return built; + } catch (err) { + setPhase('failed'); + setError( + err instanceof Error ? err.message : 'Failed to run escrow op' + ); + return null; + } + }, + [scope, signXdr] + ); + + const isTerminal = status !== 'IDLE' && isTerminalEscrowStatus(status); + + return { + run, + reset, + op, + status, + phase, + isRunning: + phase === 'starting' || + phase === 'signing' || + phase === 'submitting' || + (phase === 'polling' && !isTerminal), + isCompleted: status === 'COMPLETED', + isFailed: + status === 'FAILED' || status === 'CANCELLED' || phase === 'failed', + isTerminal, + error, + txHash: op?.txHash ?? null, + }; +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts new file mode 100644 index 00000000..533f6fc5 --- /dev/null +++ b/features/bounties/index.ts @@ -0,0 +1,83 @@ +/** + * Bounties feature public surface. Import from `@/features/bounties` rather than + * reaching into the api/ internals. + */ + +// Types (aliased from the backend-generated schema). +export type { + BountyDraft, + BountyDraftData, + UpdateBountyDraftBody, + BountyDraftPrizeTier, + BountyScopeSection, + BountyModeSection, + BountySubmissionSection, + BountyRewardSection, + BountyClaimType, + BountyEntryType, + BountySubmissionVisibility, + DraftSection, + BountyEscrowOpResponse, + EscrowOpStatus, + EscrowOpKind, + PublishBountyEscrowRequest, + CancelBountyEscrowRequest, + SelectBountyWinnersRequest, + SubmitSignedXdrRequest, + BountyWinnerSelection, + BountyWinnerDistributionEntry, + FundingMode, +} from './types'; +export { + DRAFT_SECTIONS, + TERMINAL_ESCROW_STATUSES, + isTerminalEscrowStatus, +} from './types'; + +// Query keys. +export { bountyKeys } from './api/keys'; + +// Draft client (imperative helpers). +export { + createBountyDraft, + updateBountyDraft, + getBountyDraft, + listBountyDrafts, + deleteBountyDraft, +} from './api/draft-client'; +export type { DeleteBountyDraftResult } from './api/draft-client'; + +// Draft hooks (React Query). +export { + useDraft, + useDraftList, + useCreateDraft, + useUpdateDraft, + useDeleteDraft, +} from './api/use-draft'; + +// Escrow client (typed openapi-fetch). +export { + publishBountyEscrow, + cancelBountyEscrow, + selectBountyWinners, + submitSignedBountyEscrow, + getBountyEscrowOp, +} from './api/escrow-client'; + +// Escrow hooks (React Query: polling primitive, mutation wrappers, op runner). +export { + useEscrowOp, + useEscrowOpRunner, + usePublishBountyEscrow, + useSelectBountyWinners, + useCancelBountyEscrow, +} from './api/use-escrow'; +export type { + EscrowOpScope, + SignXdrFn, + EscrowRunPhase, + UseEscrowOpOptions, + UseEscrowOpRunnerOptions, + EscrowOpRunner, +} from './api/use-escrow'; diff --git a/features/bounties/types.ts b/features/bounties/types.ts new file mode 100644 index 00000000..d2aee8bb --- /dev/null +++ b/features/bounties/types.ts @@ -0,0 +1,75 @@ +/** + * Bounty feature types. + * + * Every server shape is aliased from the backend-generated OpenAPI schema + * (lib/api/generated/schema.d.ts), so they never drift from boundless-nestjs. + * Run `npm run codegen` after a backend DTO change to refresh them. Do not + * hand-write server DTOs here. + */ +import type { Schemas } from '@/lib/api'; + +// ── Draft ──────────────────────────────────────────────────────────────────── + +/** Full draft as returned by GET/PATCH /draft/:id. */ +export type BountyDraft = Schemas['BountyDraftResponseDto']; +/** Section-keyed draft payload (scope, mode, submission, reward). */ +export type BountyDraftData = Schemas['BountyDraftDataDto']; +/** Flat draft-update body: send any subset of sections in one PATCH. */ +export type UpdateBountyDraftBody = Schemas['UpdateBountyDraftDto']; +/** Prize tier exposed on the draft response (position + decimal amount). */ +export type BountyDraftPrizeTier = Schemas['BountyDraftPrizeTierDto']; + +// Section DTOs (the wire shape the backend persists + returns). +export type BountyScopeSection = Schemas['BountyScopeSectionDto']; +export type BountyModeSection = Schemas['BountyModeSectionDto']; +export type BountySubmissionSection = Schemas['BountySubmissionSectionDto']; +export type BountyRewardSection = Schemas['BountyRewardSectionDto']; + +/** Two-axis taxonomy, derived from the generated section DTO so it stays in + * lockstep with the backend enums (supersedes the local stubs in the ModeTab). */ +export type BountyClaimType = BountyModeSection['claimType']; +export type BountyEntryType = BountyModeSection['entryType']; +export type BountySubmissionVisibility = NonNullable< + BountySubmissionSection['submissionVisibility'] +>; + +/** The four editable wizard sections, in order. */ +export const DRAFT_SECTIONS = [ + 'scope', + 'mode', + 'submission', + 'reward', +] as const; +export type DraftSection = (typeof DRAFT_SECTIONS)[number]; + +// ── Escrow ─────────────────────────────────────────────────────────────────── + +/** The EscrowOp row returned by every bounty escrow endpoint. */ +export type BountyEscrowOpResponse = Schemas['BountyEscrowOpResponseDto']; +/** Op lifecycle status (derived from the generated union). */ +export type EscrowOpStatus = BountyEscrowOpResponse['status']; +/** Contract operation kind (derived from the generated union). */ +export type EscrowOpKind = BountyEscrowOpResponse['kind']; + +export type PublishBountyEscrowRequest = Schemas['PublishBountyEscrowDto']; +export type CancelBountyEscrowRequest = Schemas['CancelBountyEscrowDto']; +export type SelectBountyWinnersRequest = Schemas['SelectBountyWinnersDto']; +export type SubmitSignedXdrRequest = Schemas['BountySubmitSignedXdrDto']; +export type BountyWinnerSelection = Schemas['BountyWinnerSelectionDto']; +export type BountyWinnerDistributionEntry = + Schemas['BountyWinnerDistributionEntryDto']; + +/** Signing path for an escrow op. */ +export type FundingMode = NonNullable< + PublishBountyEscrowRequest['fundingMode'] +>; + +/** Terminal op states. Polling should stop once one is reached. */ +export const TERMINAL_ESCROW_STATUSES: readonly EscrowOpStatus[] = [ + 'COMPLETED', + 'FAILED', + 'CANCELLED', +]; + +export const isTerminalEscrowStatus = (status: EscrowOpStatus): boolean => + TERMINAL_ESCROW_STATUSES.includes(status); diff --git a/lib/api/generated/schema.d.ts b/lib/api/generated/schema.d.ts index 4ff07960..92e9db7b 100644 --- a/lib/api/generated/schema.d.ts +++ b/lib/api/generated/schema.d.ts @@ -3598,6 +3598,70 @@ export interface paths { patch?: never; trace?: never; }; + '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/board': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the winners board + * @description One row per prize placement (overall and per-track), each with the score-ranked default pick, the current selection, the candidate list to choose from, and a conflict flag when a project already holds another placement. Drives the organizer "review and pick winners" step. + */ + get: operations['OrganizationHackathonsJudgingController_winnersBoard']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/placements/{placementId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Pick the winner for a placement + * @description Pins a submission to a prize placement as an organizer override (saved as a draft until results are published). A project already holding another placement is allowed; the UI confirms the stacking inline. + */ + put: operations['OrganizationHackathonsJudgingController_setPlacementWinner']; + post?: never; + /** + * Clear an organizer pick for a placement + * @description Removes the organizer override draft for a placement, reverting it to the score-based default. Only valid before results are published. + */ + delete: operations['OrganizationHackathonsJudgingController_clearPlacementWinner']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/placements/{placementId}/withhold': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Leave a placement unawarded + * @description Deliberately leaves a prize placement vacant ("no submission earned this prize"). Clears any pick and marks it withheld so the allocator skips it. Distinct from clearing back to the score-based default. Only valid before results are published. + */ + post: operations['OrganizationHackathonsJudgingController_withholdPlacement']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/invitations': { parameters: { query?: never; @@ -7357,6 +7421,94 @@ export interface paths { patch?: never; trace?: never; }; + '/api/organizations/{organizationId}/bounties/draft/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a bounty draft for resume + * @description Returns the current section-keyed state of a bounty draft. + */ + get: operations['OrganizationBountiesDraftsController_getDraft']; + put?: never; + post?: never; + /** + * Delete a bounty draft + * @description Deletes an unpublished bounty (draft / draft_awaiting_funding). + */ + delete: operations['OrganizationBountiesDraftsController_deleteDraft']; + options?: never; + head?: never; + /** + * Update one or more sections of a bounty draft + * @description Applies any subset of wizard sections in a single PATCH. Send one section for a per-step "Continue", or several for "Save draft". Each present section is validated and transformed independently, then merged into one write. The reward section replaces the prize tiers. + */ + patch: operations['OrganizationBountiesDraftsController_updateDraft']; + trace?: never; + }; + '/api/organizations/{organizationId}/bounties': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get an organization's published bounties + * @description Lists bounties for an organization, newest first. + */ + get: operations['OrganizationBountiesDraftsController_getOrganizationBounties']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/organizations/{organizationId}/bounties/draft': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a new bounty draft for an organization + * @description Creates an empty bounty in draft status that organization members can edit section by section through the Configure wizard. + */ + post: operations['OrganizationBountiesDraftsController_createDraft']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/organizations/{organizationId}/bounties/drafts': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get an organization's bounty drafts + * @description Lists draft and draft_awaiting_funding bounties for an organization. + */ + get: operations['OrganizationBountiesDraftsController_getOrganizationDrafts']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/organizations/{organizationId}/bounties/{id}/escrow/publish': { parameters: { query?: never; @@ -16326,6 +16478,10 @@ export interface components { }; metadata: components['schemas']['JudgingResultsMetadataDto']; }; + SetPlacementWinnerDto: { + /** @description The submission to award this placement to. */ + submissionId: string; + }; InviteJudgeDto: { /** @example judge@example.com */ email: string; @@ -17810,6 +17966,93 @@ export interface components { reason: string; }; Function: Record; + BountyScopeSectionDto: { + /** @description Bounty title */ + title: string; + /** @description Bounty description */ + description: string; + /** Format: uri */ + githubIssueUrl?: string | null; + githubIssueNumber?: number | null; + projectId?: string | null; + bountyWindowId?: string | null; + }; + BountyModeSectionDto: { + /** @enum {string} */ + claimType: 'SINGLE_CLAIM' | 'COMPETITION'; + /** @enum {string} */ + entryType: 'OPEN' | 'APPLICATION_LIGHT' | 'APPLICATION_FULL'; + }; + BountySubmissionSectionDto: { + /** Format: date-time */ + submissionDeadline: string; + /** Format: date-time */ + applicationWindowCloseAt?: string | null; + maxApplicants?: number | null; + shortlistSize?: number | null; + reputationMinimum?: number | null; + /** @enum {string} */ + submissionVisibility?: 'ORGANIZER_ONLY' | 'HIDDEN_UNTIL_DEADLINE'; + /** @description Credits required to apply (anti-spam). Supplied to the contract at publish via PublishBountyEscrowDto; not a Bounty column. */ + applicationCreditCost?: number | null; + }; + BountyPrizeTierInputDto: { + /** @description 1 = 1st place; unique within a bounty */ + position: number; + /** @description Tier amount as a positive decimal string */ + amount: string; + passMark?: number | null; + }; + BountyRewardSectionDto: { + /** @description Token / currency code the prize is denominated in */ + rewardCurrency: string; + /** @description 1 tier for single claim; 1-3 tiers for a competition (multiple winners). */ + prizeTiers: components['schemas']['BountyPrizeTierInputDto'][]; + }; + BountyDraftDataDto: { + scope?: components['schemas']['BountyScopeSectionDto']; + mode?: components['schemas']['BountyModeSectionDto']; + submission?: components['schemas']['BountySubmissionSectionDto']; + reward?: components['schemas']['BountyRewardSectionDto']; + }; + BountyDraftPrizeTierDto: { + position: number; + /** @description Tier amount as a decimal string */ + amount: string; + passMark?: number | null; + }; + BountyDraftResponseDto: { + id: string; + /** + * @description Bounty lifecycle state (lowercase). + * @example draft + */ + status: string; + /** @description First incomplete step (1-based) */ + currentStep: number; + completedSteps: string[]; + /** @description Plain mode label derived from (entryType, claimType, winner count), e.g. "Open competition (multiple winners)". */ + modeLabel?: string | null; + data: components['schemas']['BountyDraftDataDto']; + prizeTiers: components['schemas']['BountyDraftPrizeTierDto'][]; + isValidForPublish: boolean; + /** @description Per-section validation messages, keyed by step */ + validationErrors: { + [key: string]: Record[]; + }; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + UpdateBountyDraftDto: { + scope?: components['schemas']['BountyScopeSectionDto']; + mode?: components['schemas']['BountyModeSectionDto']; + submission?: components['schemas']['BountySubmissionSectionDto']; + reward?: components['schemas']['BountyRewardSectionDto']; + /** @description Hint that this is an autosave (no completion side effects). */ + autoSave?: boolean; + }; BountyWinnerDistributionEntryDto: { /** * @description 1-indexed winner position. @@ -25676,6 +25919,136 @@ export interface operations { }; }; }; + OrganizationHackathonsJudgingController_winnersBoard: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization ID */ + organizationId: string; + /** @description Hackathon ID or slug */ + idOrSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationHackathonsJudgingController_setPlacementWinner: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization ID */ + organizationId: string; + /** @description Hackathon ID or slug */ + idOrSlug: string; + /** @description Prize placement id */ + placementId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SetPlacementWinnerDto']; + }; + }; + responses: { + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationHackathonsJudgingController_clearPlacementWinner: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization ID */ + organizationId: string; + /** @description Hackathon ID or slug */ + idOrSlug: string; + /** @description Prize placement id */ + placementId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationHackathonsJudgingController_withholdPlacement: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization ID */ + organizationId: string; + /** @description Hackathon ID or slug */ + idOrSlug: string; + /** @description Prize placement id */ + placementId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; OrganizationHackathonsJudgingController_listInvitations: { parameters: { query?: never; @@ -32073,6 +32446,168 @@ export interface operations { }; }; }; + OrganizationBountiesDraftsController_getDraft: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + /** @description Bounty draft id */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Draft retrieved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BountyDraftResponseDto']; + }; + }; + }; + }; + OrganizationBountiesDraftsController_deleteDraft: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + /** @description Bounty draft id */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Draft deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Draft not found, not authorized, or not an unpublished draft */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationBountiesDraftsController_updateDraft: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + /** @description Bounty draft id */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateBountyDraftDto']; + }; + }; + responses: { + /** @description Draft updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BountyDraftResponseDto']; + }; + }; + /** @description Validation failed for one or more sections */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationBountiesDraftsController_getOrganizationBounties: { + parameters: { + query: { + page: number; + limit: number; + }; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bounties retrieved */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationBountiesDraftsController_createDraft: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Draft created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BountyDraftResponseDto']; + }; + }; + /** @description User is not a member of the organization */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + OrganizationBountiesDraftsController_getOrganizationDrafts: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Drafts retrieved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BountyDraftResponseDto'][]; + }; + }; + }; + }; OrganizationBountiesEscrowController_publish: { parameters: { query?: never;