Skip to content

Commit

Permalink
Batch actions for admin (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
technophile-04 authored Mar 6, 2024
1 parent dad3627 commit 66fff58
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 28 deletions.
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[]" }],
} as const;

0 comments on commit 66fff58

Please sign in to comment.