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

Allow admin edit grants #94

Merged
merged 10 commits into from
Mar 25, 2024
144 changes: 144 additions & 0 deletions packages/nextjs/app/admin/_components/EditGrantModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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,
});

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,
askAmount: formData.askAmount.toString(),
},
});
notificationId = notification.loading("Updating grant");
await editGrant({
signer: address,
signature,
...formData,
});
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"
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 || !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);
Loading