Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Batch actions #61

Merged
merged 20 commits into from
Mar 6, 2024
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
54 changes: 54 additions & 0 deletions packages/nextjs/app/admin/_components/BatchActionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { forwardRef, useRef } from "react";
import { useBatchReviewGrants } from "../hooks/useBatchReviewGrants";
import { PROPOSAL_STATUS } from "~~/utils/grants";

type BatchActionModalProps = {
selectedGrants: string[];
initialTxLink?: string;
btnLabel: "Approve" | "Complete";
closeModal: () => void;
};

export const BatchActionModal = forwardRef<HTMLDialogElement, BatchActionModalProps>(
({ selectedGrants, initialTxLink, closeModal, btnLabel }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);

const { handleBatchReview, isLoading } = useBatchReviewGrants();

return (
<dialog id="action_modal" className="modal" ref={ref}>
<div className="modal-box flex flex-col space-y-3">
<form method="dialog">
{/* if there is a button in form, it will close the modal */}
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
</form>
<p className="font-bold text-lg m-0">{btnLabel} this grant</p>
<input
type="text"
ref={inputRef}
defaultValue={initialTxLink ?? ""}
placeholder="Transaction hash"
className="input input-bordered"
/>
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={async () => {
await handleBatchReview(
selectedGrants,
btnLabel === "Approve" ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED,
inputRef.current?.value,
);
closeModal();
}}
disabled={isLoading}
>
{isLoading && <span className="loading loading-spinner"></span>}
{btnLabel}
</button>
</div>
</dialog>
);
},
);

BatchActionModal.displayName = "BatchActionModal";
29 changes: 18 additions & 11 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,38 @@ const BuilderSocials = ({ socialLinks }: { socialLinks?: SocialLinks }) => {
);
};

export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => {
type GrantReviewProps = {
grant: GrantDataWithBuilder;
selected: boolean;
toggleSelection: () => void;
};
export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewProps) => {
const modalRef = useRef<HTMLDialogElement>(null);

const { data: txnHash, sendTransactionAsync } = useSendTransaction({
to: grant.builder,
value: parseEther((grant.askAmount / 2).toString()),
});
const sendTx = useTransactor();

const { handleReviewGrant, isLoading } = useReviewGrant(grant);

if (grant.status !== PROPOSAL_STATUS.PROPOSED && grant.status !== PROPOSAL_STATUS.SUBMITTED) return null;

const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
return (
<div className="border p-4 my-4">
<h3 className="font-bold">
{grant.title}
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
{grant.link && (
<a href={grant.link} className="ml-4 underline" target="_blank" rel="noopener noreferrer">
View Build
</a>
)}
</h3>
<div className="flex justify-between">
<h3 className="font-bold">
{grant.title}
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
{grant.link && (
<a href={grant.link} className="ml-4 underline" target="_blank" rel="noopener noreferrer">
View Build
</a>
)}
</h3>
<input type="checkbox" className="checkbox checkbox-primary" checked={selected} onChange={toggleSelection} />
</div>
<div className="flex gap-4 items-center">
<Address address={grant.builder} link={`https://app.buidlguidl.com/builders/${grant.builder}`} />
<BuilderSocials socialLinks={grant.builderData?.socialLinks} />
Expand Down
68 changes: 68 additions & 0 deletions packages/nextjs/app/admin/hooks/useBatchReviewGrants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT_BATCH } from "~~/utils/eip712";
import { ProposalStatusType } from "~~/utils/grants";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

type BatchReqBody = {
signer: string;
signature: `0x${string}`;
reviews: {
grantId: string;
action: ProposalStatusType;
txHash: string;
}[];
};

export const useBatchReviewGrants = () => {
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { mutate } = useSWRConfig();
const { address: connectedAddress } = useAccount();
const { trigger: postBatchReviewGrant, isMutating: isPostingBatchReviewGrant } = useSWRMutation(
`/api/grants/review`,
postMutationFetcher<BatchReqBody>,
);

const handleBatchReview = async (selectedGrants: string[], action: ProposalStatusType, txHash = "") => {
if (!connectedAddress) {
notification.error("No connected address");
return;
}

const grantReviews = selectedGrants.map(grantId => {
return {
grantId,
action,
txHash,
};
});

try {
const message = { reviews: grantReviews };
const signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT_BATCH,
primaryType: "Message",
message: message,
});

await postBatchReviewGrant({
signature: signature,
reviews: grantReviews,
signer: connectedAddress,
});
await mutate("/api/grants/review");
notification.success(`Grants reviews successfully submitted!`);
} catch (error) {
const parsedError = getParsedError(error);
notification.error(parsedError);
}
};

return {
handleBatchReview,
isLoading: isSigningMessage || isPostingBatchReviewGrant,
};
};
105 changes: 91 additions & 14 deletions packages/nextjs/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
"use client";

import { useRef, useState } from "react";
import { BatchActionModal } from "./_components/BatchActionModal";
import { GrantReview } from "./_components/GrantReview";
import useSWR from "swr";
import { GrantDataWithBuilder } from "~~/services/database/schema";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";

// ToDo. "Protect" with address header or PROTECT with signing the read.
const AdminPage = () => {
// TODO: Move the response type to a shared location
const [selectedApproveGrants, setSelectedApproveGrants] = useState<string[]>([]);
const [selectedCompleteGrants, setSelectedCompleteGrants] = useState<string[]>([]);
const [modalBtnLabel, setModalBtnLabel] = useState<"Approve" | "Complete">("Approve");
const modalRef = useRef<HTMLDialogElement>(null);

const { data, isLoading } = useSWR<{ data: GrantDataWithBuilder[] }>("/api/grants/review", {
onError: error => {
console.error("Error fetching grants", error);
notification.error("Error getting grants data");
},
onSuccess: () => {
// reset states whenver any of action is performed
setSelectedApproveGrants([]);
setSelectedCompleteGrants([]);
},
});
const grants = data?.data;

const toggleGrantSelection = (grantId: string, action: "approve" | "complete") => {
if (action === "approve") {
if (selectedApproveGrants.includes(grantId)) {
setSelectedApproveGrants(selectedApproveGrants.filter(id => id !== grantId));
} else {
setSelectedApproveGrants([...selectedApproveGrants, grantId]);
}
} else if (action === "complete") {
if (selectedCompleteGrants.includes(grantId)) {
setSelectedCompleteGrants(selectedCompleteGrants.filter(id => id !== grantId));
} else {
setSelectedCompleteGrants([...selectedCompleteGrants, grantId]);
}
}
};

const completedGrants = grants?.filter(grant => grant.status === PROPOSAL_STATUS.SUBMITTED);
const newGrants = grants?.filter(grant => grant.status === PROPOSAL_STATUS.PROPOSED);

Expand All @@ -25,19 +51,70 @@ const AdminPage = () => {
<h1 className="text-4xl font-bold">Admin page</h1>
{isLoading && <span className="loading loading-spinner"></span>}
{grants && (
<>
<h2 className="font-bold mt-8">Proposals submitted as completed:</h2>
{completedGrants?.length === 0 && <p>No completed grants</p>}
{completedGrants?.map(grant => (
<GrantReview key={grant.id} grant={grant} />
))}
<h2 className="font-bold mt-8">New grant proposal:</h2>
{newGrants?.length === 0 && <p>No new grants</p>}
{newGrants?.map(grant => (
<GrantReview key={grant.id} grant={grant} />
))}
</>
<div className="flex flex-col gap-4 mt-4">
<div>
<div className="flex justify-between items-center">
<h2 className="font-bold">Proposals submitted as completed:</h2>
{completedGrants?.length !== 0 && (
<button
className="btn btn-sm btn-primary"
onClick={async () => {
setModalBtnLabel("Complete");
if (modalRef.current) modalRef.current.showModal();
}}
disabled={selectedCompleteGrants.length === 0}
>
Batch Complete
</button>
)}
</div>
{completedGrants?.length === 0 && <p className="m-0">No completed grants</p>}
{completedGrants?.map(grant => (
<GrantReview
key={grant.id}
grant={grant}
selected={selectedCompleteGrants.includes(grant.id)}
toggleSelection={() => toggleGrantSelection(grant.id, "complete")}
/>
))}
</div>
<div>
<div className="flex justify-between items-center">
<h2 className="font-bold">New grant proposal:</h2>
{newGrants?.length !== 0 && (
<button
className="btn btn-sm btn-primary"
onClick={async () => {
setModalBtnLabel("Approve");
if (modalRef.current) modalRef.current.showModal();
}}
disabled={selectedApproveGrants.length === 0}
>
Batch Approve
</button>
)}
</div>
{newGrants?.length === 0 && <p className="m-0">No new grants</p>}
{newGrants?.map(grant => (
<GrantReview
key={grant.id}
grant={grant}
selected={selectedApproveGrants.includes(grant.id)}
toggleSelection={() => toggleGrantSelection(grant.id, "approve")}
/>
))}
</div>
</div>
)}
<BatchActionModal
ref={modalRef}
selectedGrants={modalBtnLabel === "Approve" ? selectedApproveGrants : selectedCompleteGrants}
btnLabel={modalBtnLabel}
initialTxLink={"0xdummyTransactionHash"}
closeModal={() => {
if (modalRef.current) modalRef.current.close();
}}
/>
</div>
);
};
Expand Down
60 changes: 58 additions & 2 deletions packages/nextjs/app/api/grants/review/route.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { getAllGrantsForReview } from "~~/services/database/grants";
import { NextRequest, NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { getAllGrantsForReview, reviewGrant } from "~~/services/database/grants";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT_BATCH } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

export const dynamic = "force-dynamic";

Expand All @@ -16,3 +20,55 @@ export async function GET() {
);
}
}

type BatchReqBody = {
signer: string;
signature: `0x${string}`;
reviews: {
grantId: string;
action: ProposalStatusType;
txHash: string;
}[];
};

export async function POST(req: NextRequest) {
const { signer, signature, reviews } = (await req.json()) as BatchReqBody;

// Ensure all actions are valid
const validActions = Object.values(PROPOSAL_STATUS);
if (!reviews.every(review => validActions.includes(review.action))) {
console.error("Invalid action in batch", reviews);
return NextResponse.json({ error: "Invalid action in batch" }, { status: 400 });
}

// Only admins can review grants
const signerData = await findUserByAddress(signer);
if (signerData.data?.role !== "admin") {
console.error("Unauthorized", signer);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const recoveredAddress = await recoverTypedDataAddress({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT_BATCH,
primaryType: "Message",
message: { reviews },
signature,
});

if (recoveredAddress !== signer) {
console.error("Signature error in batch", recoveredAddress, signer);
return NextResponse.json({ error: "Unauthorized in batch" }, { status: 401 });
}

try {
for (const review of reviews) {
await reviewGrant(review.grantId, review.action, review.txHash);
}
} catch (error) {
console.error("Error processing batch grant review", error);
return NextResponse.json({ error: "Error processing batch grant review" }, { status: 500 });
}

return NextResponse.json({ success: true });
}
10 changes: 9 additions & 1 deletion packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const EIP_712_TYPES__APPLY_FOR_GRANT = {
],
} as const;

// ToDo. We could add more fields (grant title, builder, etc)
export const EIP_712_TYPES__REVIEW_GRANT = {
Message: [
{ name: "grantId", type: "string" },
Expand All @@ -30,3 +29,12 @@ export const EIP_712_TYPES__SUBMIT_GRANT = {
{ name: "link", type: "string" },
],
} as const;

export const EIP_712_TYPES__REVIEW_GRANT_BATCH = {
GrantReview: [
{ name: "grantId", type: "string" },
{ name: "action", type: "string" },
{ name: "txHash", type: "string" },
],
Message: [{ name: "reviews", type: "GrantReview[]" }],
carletex marked this conversation as resolved.
Show resolved Hide resolved
} as const;
Loading