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
61 changes: 61 additions & 0 deletions hooks/use-application-contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

export type ApplicationContractClient = {
apply: (params: {
applicant: string;
bountyId: bigint;
proposal: string;
}) => Promise<{ txHash: string }>;
selectApplicant: (params: {
creator: string;
bountyId: bigint;
applicant: string;
}) => Promise<{ txHash: string }>;
submitWork: (params: {
contributor: string;
bountyId: bigint;
workCid: string;
}) => Promise<{ txHash: string }>;
approveSubmission: (params: {
creator: string;
bountyId: bigint;
points: number;
}) => Promise<{ txHash: string }>;
applyForSlot: (params: {
applicant: string;
bountyId: bigint;
}) => Promise<{ txHash: string }>;
};

export type ApplicationErrorCode =
| "missing_contract_bindings"
| "already_applied"
| "tx_failed";

export class ApplicationError extends Error {
code: ApplicationErrorCode;
constructor(code: ApplicationErrorCode, message: string) {
super(message);
this.code = code;
}
}

export function toBountyIdBigInt(id: string): bigint {
if (/^\d+$/.test(id)) return BigInt(id);
const hex = id.replace(/-/g, "");
if (/^[0-9a-f]+$/i.test(hex)) return BigInt(`0x${hex}`);
throw new ApplicationError("tx_failed", `Invalid bounty ID: "${id}"`);
}

export function resolveApplicationClient(): ApplicationContractClient {
const client = (
globalThis as { __applicationContracts?: ApplicationContractClient }
).__applicationContracts;
if (!client) {
throw new ApplicationError(
"missing_contract_bindings",
"Application contract bindings unavailable. Ensure bindings are loaded.",
);
}
return client;
}
256 changes: 256 additions & 0 deletions hooks/use-application-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { bountyKeys } from "@/lib/query/query-keys";
import { type BountyQuery } from "@/lib/graphql/generated";
import {
resolveApplicationClient,
toBountyIdBigInt,
} from "./use-application-contracts";

type DeclinedApplicationRecord = {
id?: string;
bountyId?: string;
applicantAddress?: string;
status?: string;
declineReason?: string;
declinedAt?: string;
};

type BountyWithApplications = BountyQuery & {
bounty?: BountyQuery["bounty"] & {
applications?: DeclinedApplicationRecord[];
};
};

export function useApplyToBounty() {
const qc = useQueryClient();

return useMutation({
mutationFn: async ({
bountyId,
applicantAddress,
proposal,
}: {
bountyId: string;
applicantAddress: string;
proposal: string;
}) => {
const client = resolveApplicationClient();
return client.apply({
applicant: applicantAddress,
bountyId: toBountyIdBigInt(bountyId),
proposal,
});
},
onSettled: (_r, _e, v) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) });
},
});
}

export function useSelectApplicant() {
const qc = useQueryClient();

return useMutation({
mutationFn: async ({
bountyId,
creatorAddress,
applicantAddress,
}: {
bountyId: string;
creatorAddress: string;
applicantAddress: string;
}) => {
const client = resolveApplicationClient();
return client.selectApplicant({
creator: creatorAddress,
bountyId: toBountyIdBigInt(bountyId),
applicant: applicantAddress,
});
},
onMutate: async ({ bountyId }) => {
await qc.cancelQueries({ queryKey: bountyKeys.detail(bountyId) });
const prev = qc.getQueryData<BountyQuery>(bountyKeys.detail(bountyId));
if (prev?.bounty) {
qc.setQueryData<BountyQuery>(bountyKeys.detail(bountyId), {
...prev,
bounty: {
...prev.bounty,
status: "IN_PROGRESS",
updatedAt: new Date().toISOString(),
},
});
}
return { prev, bountyId };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(bountyKeys.detail(ctx.bountyId), ctx.prev);
},
onSettled: (_r, _e, v) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useDeclineApplicant() {
const qc = useQueryClient();

return useMutation({
mutationFn: async ({
bountyId,
applicantAddress,
reason,
}: {
bountyId: string;
applicantAddress: string;
reason?: string;
}) => {
return {
bountyId,
applicantAddress,
reason: reason?.trim() || undefined,
declinedAt: new Date().toISOString(),
};
},
Comment on lines +100 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

This decline mutation never reaches a source of truth.

Lines 110-115 only echo the payload and a timestamp. Since Lines 161-163 then invalidate the optimistic cache, the declined applicant will come back on the next refetch because no contract/API write happened here. This needs a real persistence path before the hook is safe to use.

Also applies to: 161-163

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/use-application-mutations.ts` around lines 100 - 116, The decline
mutation in use-application-mutations currently only returns the input payload
and a timestamp, so it never writes to a real source of truth. Update the
mutationFn for the decline flow to persist the decline through the actual
contract/API layer used by this hook, using the existing bountyId,
applicantAddress, and reason fields, and only return data after that write
succeeds. Keep the optimistic cache behavior, but ensure the refetch
invalidation in the same hook is backed by a real persistence call so the
declined applicant stays declined after refetch.


onMutate: async ({ bountyId, applicantAddress, reason }) => {
await qc.cancelQueries({ queryKey: bountyKeys.detail(bountyId) });

const prev = qc.getQueryData<BountyWithApplications>(
bountyKeys.detail(bountyId),
);

if (prev?.bounty?.applications) {
const declinedAt = new Date().toISOString();

qc.setQueryData<BountyWithApplications>(bountyKeys.detail(bountyId), {
...prev,
bounty: {
...prev.bounty,
applications: prev.bounty.applications
.map((application) =>
application.applicantAddress === applicantAddress
? {
...application,
status: "DECLINED",
declineReason: reason?.trim() || undefined,
declinedAt,
}
: application,
)
.filter(
(application) =>
application.applicantAddress !== applicantAddress,
),
updatedAt: declinedAt,
},
});
}

return { prev, bountyId };
},

onError: (_error, _variables, context) => {
if (context?.prev) {
qc.setQueryData(bountyKeys.detail(context.bountyId), context.prev);
}
},

onSettled: (_result, _error, variables) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(variables.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useSubmitApplicationWork() {
const qc = useQueryClient();

return useMutation({
mutationFn: async ({
bountyId,
contributorAddress,
workCid,
}: {
bountyId: string;
contributorAddress: string;
workCid: string;
}) => {
const client = resolveApplicationClient();
return client.submitWork({
contributor: contributorAddress,
bountyId: toBountyIdBigInt(bountyId),
workCid,
});
},
onMutate: async ({ bountyId }) => {
await qc.cancelQueries({ queryKey: bountyKeys.detail(bountyId) });
const prev = qc.getQueryData<BountyQuery>(bountyKeys.detail(bountyId));
if (prev?.bounty) {
qc.setQueryData<BountyQuery>(bountyKeys.detail(bountyId), {
...prev,
bounty: {
...prev.bounty,
status: "UNDER_REVIEW",
updatedAt: new Date().toISOString(),
},
});
}
return { prev, bountyId };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(bountyKeys.detail(ctx.bountyId), ctx.prev);
},
onSettled: (_r, _e, v) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useApproveApplicationSubmission() {
const qc = useQueryClient();

return useMutation({
mutationFn: async ({
bountyId,
creatorAddress,
points,
}: {
bountyId: string;
creatorAddress: string;
points: number;
}) => {
const client = resolveApplicationClient();
return client.approveSubmission({
creator: creatorAddress,
bountyId: toBountyIdBigInt(bountyId),
points,
});
},
onMutate: async ({ bountyId }) => {
await qc.cancelQueries({ queryKey: bountyKeys.detail(bountyId) });
const prev = qc.getQueryData<BountyQuery>(bountyKeys.detail(bountyId));
if (prev?.bounty) {
qc.setQueryData<BountyQuery>(bountyKeys.detail(bountyId), {
...prev,
bounty: {
...prev.bounty,
status: "COMPLETED",
updatedAt: new Date().toISOString(),
},
});
}
return { prev, bountyId };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(bountyKeys.detail(ctx.bountyId), ctx.prev);
},
onSettled: (_r, _e, v) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}
67 changes: 67 additions & 0 deletions hooks/use-application-review-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { bountyKeys } from "@/lib/query/query-keys";
import { fetcher } from "@/lib/graphql/client";
import {
ReviewSubmissionDocument,
type BountyQuery,
type ReviewSubmissionMutation,
type ReviewSubmissionMutationVariables,
} from "@/lib/graphql/generated";

type RequestRevisionsVars = {
bountyId: string;
submissionId: string;
feedback: string;
};

type RequestRevisionsCtx = {
prev: BountyQuery | undefined;
bountyId: string;
};

export function useRequestRevisions() {
const qc = useQueryClient();

return useMutation<
ReviewSubmissionMutation,
Error,
RequestRevisionsVars,
RequestRevisionsCtx
>({
mutationFn: ({ submissionId, feedback }) =>
fetcher<ReviewSubmissionMutation, ReviewSubmissionMutationVariables>(
ReviewSubmissionDocument,
{
input: {
submissionId,
status: "REVISION_REQUESTED",
reviewComments: feedback,
},
},
)(),
onMutate: async ({ bountyId }) => {
await qc.cancelQueries({ queryKey: bountyKeys.detail(bountyId) });
const prev = qc.getQueryData<BountyQuery>(bountyKeys.detail(bountyId));
if (prev?.bounty) {
qc.setQueryData<BountyQuery>(bountyKeys.detail(bountyId), {
...prev,
bounty: {
...prev.bounty,
status: "UNDER_REVIEW",
updatedAt: new Date().toISOString(),
},
});
}
return { prev, bountyId };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(bountyKeys.detail(ctx.bountyId), ctx.prev);
},
onSettled: (_r, _e, v) => {
qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) });
qc.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}
Loading
Loading