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 to add note #108

Merged
merged 11 commits into from
Apr 15, 2024
47 changes: 32 additions & 15 deletions packages/nextjs/app/admin/_components/ActionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@ import { useReviewGrant } from "../hooks/useReviewGrant";
import { useNetwork } from "wagmi";
import { getNetworkColor } from "~~/hooks/scaffold-eth";
import { GrantData } from "~~/services/database/schema";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { NETWORKS_EXTRA_DATA } from "~~/utils/scaffold-eth";

type ActionModalProps = {
grant: GrantData;
initialTxLink?: string;
action: ProposalStatusType;
};

export const ActionModal = forwardRef<HTMLDialogElement, ActionModalProps>(({ grant, initialTxLink }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
export const ActionModal = forwardRef<HTMLDialogElement, ActionModalProps>(({ grant, initialTxLink, action }, ref) => {
const transactionInputRef = useRef<HTMLInputElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);

const { chain } = useNetwork();
const chainWithExtraAttributes = chain ? { ...chain, ...NETWORKS_EXTRA_DATA[chain.id] } : undefined;

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

const acceptStatus = grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED;
const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
const actionLabel =
action === PROPOSAL_STATUS.REJECTED ? "Reject" : action === PROPOSAL_STATUS.APPROVED ? "Approve" : "Complete";
return (
<dialog id="action_modal" className="modal" ref={ref}>
<div className="modal-box flex flex-col space-y-3">
Expand All @@ -29,27 +31,42 @@ export const ActionModal = forwardRef<HTMLDialogElement, ActionModalProps>(({ gr
<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">{acceptLabel} this grant</p>
<p className="font-bold text-lg m-0">{actionLabel} this grant</p>
{chainWithExtraAttributes && (
<p className="m-0 text-sm" style={{ color: getNetworkColor(chainWithExtraAttributes, true) }}>
{chainWithExtraAttributes.name}
</p>
)}
</div>
<input
type="text"
ref={inputRef}
defaultValue={initialTxLink ?? ""}
placeholder="Transaction hash"
className="input input-bordered"
/>
{action !== PROPOSAL_STATUS.REJECTED && (
<div className="w-full flex-col space-y-1">
<p className="m-0 font-semibold text-base">Transction Hash</p>
<input
type="text"
ref={transactionInputRef}
defaultValue={initialTxLink ?? ""}
placeholder="Transaction hash"
className="input input-bordered w-full"
/>
</div>
)}
{(action === PROPOSAL_STATUS.APPROVED || action === PROPOSAL_STATUS.REJECTED) && (
<div className="w-full flex-col space-y-1">
<p className="m-0 font-semibold text-base">Note (optional)</p>
<textarea
ref={noteInputRef}
placeholder={`Note for the builder (${actionLabel})`}
className="input input-bordered w-full py-2 h-24"
/>
</div>
)}
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={() => handleReviewGrant(acceptStatus, inputRef.current?.value)}
onClick={() => handleReviewGrant(action, transactionInputRef?.current?.value, noteInputRef?.current?.value)}
disabled={isLoading}
>
{isLoading && <span className="loading loading-spinner"></span>}
{acceptLabel}
{actionLabel}
</button>
</div>
</dialog>
Expand Down
38 changes: 23 additions & 15 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import Image from "next/image";
import { useReviewGrant } from "../hooks/useReviewGrant";
import { ActionModal } from "./ActionModal";
import { EditGrantModal } from "./EditGrantModal";
import useSWR from "swr";
Expand All @@ -13,7 +12,7 @@ import TwitterIcon from "~~/components/assets/TwitterIcon";
import { Address } from "~~/components/scaffold-eth";
import { useScaffoldContractWrite } from "~~/hooks/scaffold-eth";
import { GrantData, GrantDataWithBuilder, SocialLinks } from "~~/services/database/schema";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

const BuilderSocials = ({ socialLinks }: { socialLinks?: SocialLinks }) => {
if (!socialLinks) return null;
Expand Down Expand Up @@ -52,6 +51,9 @@ type GrantReviewProps = {
export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewProps) => {
const actionModalRef = useRef<HTMLDialogElement>(null);
const editGrantModalRef = useRef<HTMLDialogElement>(null);
const [reviewAction, setReviewAction] = useState<ProposalStatusType>(
grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED,
);
const { chain: connectedChain } = useNetwork();

const { data: txResult, writeAsync: splitEqualETH } = useScaffoldContractWrite({
Expand All @@ -60,8 +62,6 @@ export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewPro
args: [undefined, undefined],
});

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

// Fetch all grants for this builder to show count and detail in tooltip
const { data: grants, error } = useSWR<GrantData[]>(`/api/builders/${grant.builder}/grants`);

Expand Down Expand Up @@ -176,45 +176,53 @@ export const GrantReview = ({ grant, selected, toggleSelection }: GrantReviewPro
<p>{grant.description}</p>
<div className="flex gap-2 lg:gap-4 mt-4 justify-between">
<button
className={`btn btn-sm btn-error ${isLoading ? "opacity-50" : ""}`}
onClick={() => handleReviewGrant(PROPOSAL_STATUS.REJECTED)}
disabled={isLoading}
className="btn btn-sm btn-error "
onClick={() => {
setReviewAction(PROPOSAL_STATUS.REJECTED);
actionModalRef.current?.showModal();
}}
disabled={isCompleteActionDisabled}
>
Reject
</button>
<div className="flex gap-2 lg:gap-4">
<button
className={`btn btn-sm btn-success border-2 bg-transparent ${
isLoading ? "opacity-50" : ""
} ${completeActionDisableClassName}`}
className={`btn btn-sm btn-success border-2 bg-transparent ${completeActionDisableClassName}`}
data-tip={completeActionDisableToolTip}
onClick={async () => {
const resHash = await splitEqualETH({
args: [[grant.builder], [parseEther((grant.askAmount / 2).toString())]],
value: parseEther((grant.askAmount / 2).toString()),
});

setReviewAction(
grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED,
);
// Transactor eats the error, so we need to handle by checking resHash
if (resHash && actionModalRef.current) actionModalRef.current.showModal();
}}
disabled={isLoading || isCompleteActionDisabled}
disabled={isCompleteActionDisabled}
>
Send 50%
</button>
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""} ${completeActionDisableClassName}`}
className={`btn btn-sm btn-success ${completeActionDisableClassName}`}
data-tip={completeActionDisableToolTip}
onClick={() => {
setReviewAction(
grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED,
);
if (actionModalRef.current) actionModalRef.current.showModal();
}}
disabled={isLoading || isCompleteActionDisabled}
disabled={isCompleteActionDisabled}
>
{acceptLabel}
</button>
</div>
</div>
</div>
<EditGrantModal ref={editGrantModalRef} grant={grant} closeModal={() => editGrantModalRef?.current?.close()} />
<ActionModal ref={actionModalRef} grant={grant} initialTxLink={txResult?.hash} />
<ActionModal ref={actionModalRef} grant={grant} initialTxLink={txResult?.hash} action={reviewAction} />
</div>
);
};
45 changes: 31 additions & 14 deletions packages/nextjs/app/admin/hooks/useReviewGrant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useNetwork, useSignTypedData } from "wagmi";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { ProposalStatusType } from "~~/utils/grants";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT, EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

Expand All @@ -13,6 +13,7 @@ type ReqBody = {
action: ProposalStatusType;
txHash: string;
txChainId: string;
note?: string;
};

export const useReviewGrant = (grant: GrantData) => {
Expand All @@ -27,25 +28,40 @@ export const useReviewGrant = (grant: GrantData) => {

const isLoading = isSigningMessage || isPostingNewGrant;

const handleReviewGrant = async (action: ProposalStatusType, txnHash = "") => {
const handleReviewGrant = async (action: ProposalStatusType, txnHash = "", note: string | undefined = undefined) => {
if (!address || !connectedChain) {
notification.error("Please connect your wallet");
return;
}

let signature;
try {
signature = await signTypedDataAsync({
domain: { ...EIP_712_DOMAIN, chainId: connectedChain.id },
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
txHash: txnHash,
txChainId: connectedChain.id.toString(),
},
});
if (action === PROPOSAL_STATUS.APPROVED || action === PROPOSAL_STATUS.REJECTED) {
signature = await signTypedDataAsync({
domain: { ...EIP_712_DOMAIN, chainId: connectedChain.id },
types: EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
txHash: txnHash,
txChainId: connectedChain.id.toString(),
note: note ?? "",
},
});
} else {
signature = await signTypedDataAsync({
domain: { ...EIP_712_DOMAIN, chainId: connectedChain.id },
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
txHash: txnHash,
txChainId: connectedChain.id.toString(),
},
});
}
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
Expand All @@ -61,6 +77,7 @@ export const useReviewGrant = (grant: GrantData) => {
action,
txHash: txnHash,
txChainId: connectedChain.id.toString(),
note,
});
await mutate("/api/grants/review");
notification.remove(notificationId);
Expand Down
51 changes: 36 additions & 15 deletions packages/nextjs/app/api/grants/[grantId]/review/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { reviewGrant } from "~~/services/database/grants";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT, EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

type ReqBody = {
Expand All @@ -11,11 +11,12 @@ type ReqBody = {
action: ProposalStatusType;
txHash: string;
txChainId: string;
note?: string;
};

export async function POST(req: NextRequest, { params }: { params: { grantId: string } }) {
const { grantId } = params;
const { signature, signer, action, txHash, txChainId } = (await req.json()) as ReqBody;
const { signature, signer, action, txHash, txChainId, note } = (await req.json()) as ReqBody;

// Validate action is valid
const validActions = Object.values(PROPOSAL_STATUS);
Expand All @@ -24,19 +25,38 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}

// Validate Signature
const recoveredAddress = await recoverTypedDataAddress({
domain: { ...EIP_712_DOMAIN, chainId: Number(txChainId) },
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grantId,
action: action,
txHash,
txChainId,
},
signature,
});
let recoveredAddress: string;

// If action is approved or rejected, include note in signature
if (action === PROPOSAL_STATUS.APPROVED || action === PROPOSAL_STATUS.REJECTED) {
recoveredAddress = await recoverTypedDataAddress({
domain: { ...EIP_712_DOMAIN, chainId: Number(txChainId) },
types: EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE,
primaryType: "Message",
message: {
grantId: grantId,
action: action,
txHash,
txChainId,
note: note ?? "",
},
signature,
});
} else {
// Validate Signature
recoveredAddress = await recoverTypedDataAddress({
domain: { ...EIP_712_DOMAIN, chainId: Number(txChainId) },
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grantId,
action: action,
txHash,
txChainId,
},
signature,
});
}

if (recoveredAddress !== signer) {
console.error("Signature error", recoveredAddress, signer);
Expand All @@ -56,6 +76,7 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
grantId,
action,
txHash,
note,
txChainId,
});
} catch (error) {
Expand Down
Loading
Loading