diff --git a/packages/nextjs/app/admin/_components/BatchActionModal.tsx b/packages/nextjs/app/admin/_components/BatchActionModal.tsx new file mode 100644 index 0000000..be7a0fb --- /dev/null +++ b/packages/nextjs/app/admin/_components/BatchActionModal.tsx @@ -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( + ({ selectedGrants, initialTxLink, closeModal, btnLabel }, ref) => { + const inputRef = useRef(null); + + const { handleBatchReview, isLoading } = useBatchReviewGrants(); + + return ( + +
+
+ {/* if there is a button in form, it will close the modal */} + +
+

{btnLabel} this grant

+ + +
+
+ ); + }, +); + +BatchActionModal.displayName = "BatchActionModal"; diff --git a/packages/nextjs/app/admin/_components/GrantReview.tsx b/packages/nextjs/app/admin/_components/GrantReview.tsx index 3b7f27a..d6fc8a6 100644 --- a/packages/nextjs/app/admin/_components/GrantReview.tsx +++ b/packages/nextjs/app/admin/_components/GrantReview.tsx @@ -39,7 +39,12 @@ 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(null); const { data: txnHash, sendTransactionAsync } = useSendTransaction({ @@ -47,7 +52,6 @@ export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => { 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; @@ -55,15 +59,18 @@ export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => { const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete"; return (
-

- {grant.title} - ({grant.id}) - {grant.link && ( - - View Build - - )} -

+
+

+ {grant.title} + ({grant.id}) + {grant.link && ( + + View Build + + )} +

+ +
diff --git a/packages/nextjs/app/admin/hooks/useBatchReviewGrants.ts b/packages/nextjs/app/admin/hooks/useBatchReviewGrants.ts new file mode 100644 index 0000000..4325d94 --- /dev/null +++ b/packages/nextjs/app/admin/hooks/useBatchReviewGrants.ts @@ -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, + ); + + 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, + }; +}; diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx index dc3e7bd..d06bac5 100644 --- a/packages/nextjs/app/admin/page.tsx +++ b/packages/nextjs/app/admin/page.tsx @@ -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([]); + const [selectedCompleteGrants, setSelectedCompleteGrants] = useState([]); + const [modalBtnLabel, setModalBtnLabel] = useState<"Approve" | "Complete">("Approve"); + const modalRef = useRef(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); @@ -25,19 +51,70 @@ const AdminPage = () => {

Admin page

{isLoading && } {grants && ( - <> -

Proposals submitted as completed:

- {completedGrants?.length === 0 &&

No completed grants

} - {completedGrants?.map(grant => ( - - ))} -

New grant proposal:

- {newGrants?.length === 0 &&

No new grants

} - {newGrants?.map(grant => ( - - ))} - +
+
+
+

Proposals submitted as completed:

+ {completedGrants?.length !== 0 && ( + + )} +
+ {completedGrants?.length === 0 &&

No completed grants

} + {completedGrants?.map(grant => ( + toggleGrantSelection(grant.id, "complete")} + /> + ))} +
+
+
+

New grant proposal:

+ {newGrants?.length !== 0 && ( + + )} +
+ {newGrants?.length === 0 &&

No new grants

} + {newGrants?.map(grant => ( + toggleGrantSelection(grant.id, "approve")} + /> + ))} +
+
)} + { + if (modalRef.current) modalRef.current.close(); + }} + />
); }; diff --git a/packages/nextjs/app/api/grants/review/route.tsx b/packages/nextjs/app/api/grants/review/route.tsx index a708102..ac586e1 100644 --- a/packages/nextjs/app/api/grants/review/route.tsx +++ b/packages/nextjs/app/api/grants/review/route.tsx @@ -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"; @@ -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 }); +} diff --git a/packages/nextjs/utils/eip712.ts b/packages/nextjs/utils/eip712.ts index 012abe7..ecccd25 100644 --- a/packages/nextjs/utils/eip712.ts +++ b/packages/nextjs/utils/eip712.ts @@ -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" }, @@ -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[]" }], +} as const;