Skip to content

Commit

Permalink
Allow admin edit grants (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
technophile-04 authored Mar 25, 2024
1 parent 5197a5a commit 2b28aaf
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 16 deletions.
148 changes: 148 additions & 0 deletions packages/nextjs/app/admin/_components/EditGrantModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { ChangeEvent, forwardRef, useState } from "react";
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useNetwork, useSignTypedData } from "wagmi";
import { GrantDataWithBuilder } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__EDIT_GRANT } from "~~/utils/eip712";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
import { patchMutationFetcher } from "~~/utils/swr";

type EditGrantModalProps = {
grant: GrantDataWithBuilder;
closeModal: () => void;
};

type ReqBody = {
title?: string;
description?: string;
askAmount?: number;
signature?: `0x${string}`;
signer?: string;
};

export const EditGrantModal = forwardRef<HTMLDialogElement, EditGrantModalProps>(({ grant, closeModal }, ref) => {
const [formData, setFormData] = useState({
title: grant.title,
description: grant.description,
askAmount: grant.askAmount.toString(),
});

const { address } = useAccount();
const { chain: connectedChain } = useNetwork();
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();

const { trigger: editGrant, isMutating } = useSWRMutation(`/api/grants/${grant.id}`, patchMutationFetcher<ReqBody>);
const { mutate } = useSWRConfig();

const isLoading = isSigningMessage || isMutating;

const handleInputChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prevFormData => ({
...prevFormData,
[name]: value,
}));
};

const handleEditGrant = async () => {
if (!address || !connectedChain) {
notification.error("Please connect your wallet");
return;
}

let notificationId: string | undefined;
try {
const signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__EDIT_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
title: formData.title,
description: formData.description,
// Converting this to number with parseFloat and again to string (similar to backend),
// if not it generates different signature with .23 and 0.23
askAmount: parseFloat(formData.askAmount).toString(),
},
});
notificationId = notification.loading("Updating grant");
await editGrant({
signer: address,
signature,
...formData,
askAmount: parseFloat(formData.askAmount),
});
await mutate("/api/grants/review");
notification.remove(notificationId);
notification.success(`Successfully updated grant ${grant.id}`);
closeModal();
} catch (error) {
console.error("Error editing grant", error);
const errorMessage = getParsedError(error);
notification.error(errorMessage);
} finally {
if (notificationId) notification.remove(notificationId);
}
};

return (
<dialog id="edit_grant_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>
<div className="flex justify-between items-center">
<p className="font-bold text-lg m-0">
Edit grant
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
</p>
</div>
<div className="w-full flex-col gap-1">
<p className="m-0 font-semibold text-base">Title</p>
<input
type="text"
placeholder="title"
name="title"
value={formData.title}
className="input input-sm input-bordered w-full"
onChange={handleInputChange}
/>
</div>
<div className="w-full flex-col gap-1">
<p className="m-0 font-semibold text-base">Description</p>
<textarea
name="description"
placeholder="description"
value={formData.description}
className="textarea textarea-md textarea-bordered w-full"
rows={5}
onChange={handleInputChange}
/>
</div>
<div className="w-full flex-col gap-1">
<p className="m-0 font-semibold text-base">Amount</p>
<input
type="number"
name="askAmount"
disabled={grant.status === "submitted"}
placeholder="ask amount"
value={formData.askAmount}
className="input input-sm input-bordered w-full"
onChange={handleInputChange}
/>
</div>
<button
className={`btn btn-md btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={handleEditGrant}
disabled={isLoading}
>
{isLoading && <span className="loading loading-spinner"></span>}
Submit
</button>
</div>
</dialog>
);
});

EditGrantModal.displayName = "EditGrantModal";
17 changes: 12 additions & 5 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useRef } from "react";
import Image from "next/image";
import { useReviewGrant } from "../hooks/useReviewGrant";
import { ActionModal } from "./ActionModal";
import { EditGrantModal } from "./EditGrantModal";
import { parseEther } from "viem";
import { useNetwork } from "wagmi";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { PencilSquareIcon } from "@heroicons/react/24/outline";
import TelegramIcon from "~~/components/assets/TelegramIcon";
import TwitterIcon from "~~/components/assets/TwitterIcon";
import { Address } from "~~/components/scaffold-eth";
Expand Down Expand Up @@ -47,7 +49,8 @@ type GrantReviewProps = {
toggleSelection: () => void;
};
export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewProps) => {
const modalRef = useRef<HTMLDialogElement>(null);
const actionModalRef = useRef<HTMLDialogElement>(null);
const editGrantModalRef = useRef<HTMLDialogElement>(null);
const { chain: connectedChain } = useNetwork();

const { data: txResult, writeAsync: splitEqualETH } = useScaffoldContractWrite({
Expand All @@ -73,14 +76,17 @@ export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewPro
return (
<div className="border-4 rounded-lg p-4 my-4">
<div className="flex justify-between mb-2">
<div className="font-bold flex flex-col gap-1 lg:gap-2 lg:flex-row items-baseline">
<div className="font-bold flex flex-col gap-1 lg:gap-2 lg:flex-row lg:flex-wrap items-baseline">
<h1 className="text-lg m-0">{grant.title}</h1>
<span className="text-sm text-gray-500">({grant.id})</span>
{grant.link && (
<a href={grant.link} className="underline text-sm" target="_blank" rel="noopener noreferrer">
View Build <ArrowTopRightOnSquareIcon className="h-4 w-4 inline" />
</a>
)}
<button className="cursor-pointer self-center" onClick={() => editGrantModalRef?.current?.showModal()}>
<PencilSquareIcon className="h-6 w-6" />
</button>
</div>
<input
type="checkbox"
Expand Down Expand Up @@ -125,7 +131,7 @@ export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewPro
value: parseEther((grant.askAmount / 2).toString()),
});
// Transactor eats the error, so we need to handle by checking resHash
if (resHash && modalRef.current) modalRef.current.showModal();
if (resHash && actionModalRef.current) actionModalRef.current.showModal();
}}
disabled={isLoading || isCompleteActionDisabled}
>
Expand All @@ -135,15 +141,16 @@ export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewPro
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""} ${completeActionDisableClassName}`}
data-tip={completeActionDisableToolTip}
onClick={() => {
if (modalRef.current) modalRef.current.showModal();
if (actionModalRef.current) actionModalRef.current.showModal();
}}
disabled={isLoading || isCompleteActionDisabled}
>
{acceptLabel}
</button>
</div>
</div>
<ActionModal ref={modalRef} grant={grant} initialTxLink={txResult?.hash} />
<EditGrantModal ref={editGrantModalRef} grant={grant} closeModal={() => editGrantModalRef?.current?.close()} />
<ActionModal ref={actionModalRef} grant={grant} initialTxLink={txResult?.hash} />
</div>
);
};
47 changes: 47 additions & 0 deletions packages/nextjs/app/api/grants/[grantId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { updateGrant } from "~~/services/database/grants";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__EDIT_GRANT } from "~~/utils/eip712";

type ReqBody = {
title?: string;
description?: string;
askAmount?: number;
signature?: `0x${string}`;
signer?: string;
};

export async function PATCH(req: NextRequest, { params }: { params: { grantId: string } }) {
try {
const { grantId } = params;
const { title, description, signature, signer, askAmount } = (await req.json()) as ReqBody;

if (!title || !description || !askAmount || typeof askAmount !== "number" || !signature || !signer) {
return NextResponse.json({ error: "Invalid form details submited" }, { status: 400 });
}

const recoveredAddress = await recoverTypedDataAddress({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__EDIT_GRANT,
primaryType: "Message",
message: { title, description, askAmount: askAmount.toString(), grantId },
signature: signature,
});
if (recoveredAddress !== signer) {
return NextResponse.json({ error: "Recovered address did not match signer" }, { status: 401 });
}

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

await updateGrant(grantId, { title, description, askAmount });
return NextResponse.json({ success: true });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Error editing grant" }, { status: 500 });
}
}
9 changes: 9 additions & 0 deletions packages/nextjs/services/database/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ export const reviewGrant = async ({ grantId, action, txHash, txChainId }: Review
}
};

export const updateGrant = async (grantId: string, grantData: Partial<GrantData>) => {
try {
await getGrantsDoc(grantId).update(grantData);
} catch (error) {
console.error("Error updating the grant:", error);
throw error;
}
};

export const getGrantsStats = async () => {
// total_eth_granted is the summation of askAmount of all completed grants
// total_active_grants is the count of grants with status "approved"
Expand Down
9 changes: 9 additions & 0 deletions packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export const EIP_712_TYPES__APPLY_FOR_GRANT = {
],
} as const;

export const EIP_712_TYPES__EDIT_GRANT = {
Message: [
...EIP_712_TYPES__APPLY_FOR_GRANT.Message,

{ name: "grantId", type: "string" },
{ name: "askAmount", type: "string" },
],
} as const;

export const EIP_712_TYPES__REVIEW_GRANT = {
Message: [
{ name: "grantId", type: "string" },
Expand Down
30 changes: 19 additions & 11 deletions packages/nextjs/utils/swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ export const fetcher = async (...args: Parameters<typeof fetch>) => {
return data;
};

export const postMutationFetcher = async <T = Record<any, any>>(url: string, { arg }: { arg: T }) => {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(arg),
});
export const makeMutationFetcher =
<T = Record<any, any>>(method: "POST" | "PUT" | "PATCH" | "DELETE") =>
async (url: string, { arg }: { arg: T }) => {
const res = await fetch(url, {
method: method,
body: JSON.stringify(arg),
});

const data = await res.json();
const data = await res.json();

if (!res.ok) {
throw new Error(data.error || "Error posting data");
}
return data;
};
if (!res.ok) {
throw new Error(data.error || `Error ${method.toLowerCase()}ing data`);
}
return data;
};

export const postMutationFetcher = <T = Record<any, any>>(url: string, arg: { arg: T }) =>
makeMutationFetcher<T>("POST")(url, arg);

export const patchMutationFetcher = <T = Record<any, any>>(url: string, arg: { arg: T }) =>
makeMutationFetcher<T>("PATCH")(url, arg);

0 comments on commit 2b28aaf

Please sign in to comment.