Skip to content

Commit

Permalink
Submit completed builds (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pabl0cks authored Mar 1, 2024
1 parent 6001b91 commit bd33702
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 3 deletions.
5 changes: 5 additions & 0 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => {
<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 gap-4 items-center">
<Address address={grant.builder} link={`https://app.buidlguidl.com/builders/${grant.builder}`} />
Expand Down
57 changes: 57 additions & 0 deletions packages/nextjs/app/api/grants/submit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { getGrantById, submitGrantBuild } from "~~/services/database/grants";
import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMIT_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS } from "~~/utils/grants";

type ReqBody = {
grantId?: string;
signer?: string;
link?: string;
signature?: `0x${string}`;
};

export async function POST(req: Request) {
try {
const { grantId, link, signer, signature } = (await req.json()) as ReqBody;
if (!grantId || !signer || !link || !signature) {
return NextResponse.json({ error: "Invalid form details submitted" }, { status: 400 });
}

// Validate Signature
const recoveredAddress = await recoverTypedDataAddress({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__SUBMIT_GRANT,
primaryType: "Message",
message: {
grantId: grantId,
action: "submit",
link,
},
signature,
});
if (recoveredAddress !== signer) {
console.error("Signature error", recoveredAddress, signer);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Validate if the grant is approved and owned by the builder
const grant = await getGrantById(grantId);
if (!grant) {
return NextResponse.json({ error: "Invalid grant" }, { status: 400 });
}
if (grant.builder !== signer) {
return NextResponse.json({ error: "Builder does not own this grant" }, { status: 401 });
}
if (grant.status !== PROPOSAL_STATUS.APPROVED) {
return NextResponse.json({ error: "Grant is not approved" }, { status: 400 });
}

await submitGrantBuild(grantId, link);

return NextResponse.json({}, { status: 201 });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Error processing form" }, { status: 500 });
}
}
119 changes: 119 additions & 0 deletions packages/nextjs/app/my-grants/_components/SubmitModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useState } from "react";
import { mutate } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMIT_GRANT } from "~~/utils/eip712";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

type ReqBody = {
grantId?: string;
status?: string;
signer?: string;
link?: string;
signature?: `0x${string}`;
};

export const SubmitModal = ({ grant, closeModal }: { grant: GrantData; closeModal: () => void }) => {
const { address: connectedAddress } = useAccount();
const [buildUrl, setBuildUrl] = useState("");
const { trigger: submitBuildLink, isMutating: isSubmittingLink } = useSWRMutation(
"/api/grants/submit",
postMutationFetcher<ReqBody>,
);

const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();

const handleSubmit = async () => {
const urlPattern = new RegExp("^(https://app\\.buidlguidl\\.com/build/)[a-z0-9-]+$");

if (!urlPattern.test(buildUrl.toLowerCase()))
return notification.error("You must submit a valid build URL (https://app.buidlguidl.com/build/...)");

let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__SUBMIT_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: "submit",
link: buildUrl,
},
});
} catch (error) {
console.error("Error signing message", error);
notification.error("Error signing message");
return;
}

let notificationId;
try {
notificationId = notification.loading("Submitting build URL");
await submitBuildLink({ grantId: grant.id, link: buildUrl, signer: connectedAddress, signature });
await mutate(`/api/builders/${connectedAddress}/grants`);
closeModal();
notification.remove(notificationId);
notification.success("Build URL submitted successfully");
} catch (error) {
notification.error("Error submitting build URL");
} finally {
if (notificationId) notification.remove(notificationId);
}
};

return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center p-8 md:p-0 z-10"
onClick={closeModal}
>
<div className="rounded-2xl bg-primary p-5 w-auto md:w-1/2 lg:w-1/3 xl:w-1/4" onClick={e => e.stopPropagation()}>
<button onClick={closeModal} className="float-right text-xs hover:underline">
Close
</button>
<h2 className="font-medium text-lg pb-2">{grant.title}</h2>
<div role="alert" className="alert border-0">
<span className="text-sm text-gray-400">
<InformationCircleIcon
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
className="stroke-info shrink-0 w-5 h-5 inline-block mr-2"
/>
First you&apos;ll need to register the build in your&nbsp;
<a
href={`https://app.buidlguidl.com/builders/${connectedAddress}`}
target="_blank"
rel="noopener noreferrer"
className="text-black-500 underline"
>
BuidlGuidl profile
</a>
&nbsp;and then submit the URL of your BG build in this form. BG Grants team will review it to complete the
grant.
</span>
</div>
<label className="block mt-4">
<span className="font-medium">Build URL</span> to be reviewed and complete grant:
</label>
<input
type="text"
value={buildUrl}
onChange={e => setBuildUrl(e.target.value)}
placeholder="https://app.buidlguidl.com/build/..."
className="placeholder: pl-[14px] mt-4 w-full p-1 rounded-lg"
/>
<button
className="mt-8 btn btn-sm btn-secondary"
disabled={isSigningMessage || isSubmittingLink}
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
);
};
20 changes: 19 additions & 1 deletion packages/nextjs/app/my-grants/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { useState } from "react";
import { SubmitModal } from "./_components/SubmitModal";
import { NextPage } from "next";
import useSWR from "swr";
import { useAccount } from "wagmi";
Expand All @@ -14,11 +16,18 @@ const badgeBgColor = {
[PROPOSAL_STATUS.REJECTED]: "bg-error",
};

// ToDo. Action (v1.0 = submit grant after completion)
const MyGrants: NextPage = () => {
const { address } = useAccount();
const { data: builderGrants, isLoading } = useSWR<GrantData[]>(address ? `/api/builders/${address}/grants` : null);

const [modalIsOpen, setModalIsOpen] = useState(false);
const [currentGrant, setCurrentGrant] = useState<GrantData | null>(null);

const openModal = (grant: GrantData) => {
setCurrentGrant(grant);
setModalIsOpen(true);
};

return (
<div className="container mx-auto max-w-screen-md mt-12">
<h1 className="text-4xl font-bold">My grants</h1>
Expand All @@ -32,8 +41,17 @@ const MyGrants: NextPage = () => {
</h3>
<p>{grant.description}</p>
<p className={`badge ${badgeBgColor[grant.status]}`}>{grant.status}</p>
{grant.status === PROPOSAL_STATUS.APPROVED && (
<button onClick={() => openModal(grant)} className="btn btn-primary float-right">
Submit build
</button>
)}
</div>
))}

{modalIsOpen && currentGrant !== null && (
<SubmitModal grant={currentGrant} closeModal={() => setModalIsOpen(false)} />
)}
</div>
);
};
Expand Down
30 changes: 28 additions & 2 deletions packages/nextjs/services/database/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

const firestoreDB = getFirestoreConnector();
const grantsCollection = firestoreDB.collection("grants");
// const getGrantsDoc = (id: string) => grantsCollection.doc(id);
// const getGrantSnapshotById = (id: string) => getGrantsDoc(id).get();
const getGrantsDoc = (id: string) => grantsCollection.doc(id);
const getGrantSnapshotById = (id: string) => getGrantsDoc(id).get();

export const createGrant = async (grantData: Omit<GrantData, "id" | "proposedAt" | "status">) => {
try {
Expand Down Expand Up @@ -148,3 +148,29 @@ export const getGrantsStats = async () => {
throw error;
}
};

export const getGrantById = async (grantId: string) => {
try {
const grantSnapshot = await getGrantSnapshotById(grantId);
if (!grantSnapshot.exists) {
return null;
}
return { id: grantSnapshot.id, ...grantSnapshot.data() } as GrantData;
} catch (error) {
console.error("Error getting grant by id:", error);
throw error;
}
};

export const submitGrantBuild = async (grantId: string, link: string) => {
const status = PROPOSAL_STATUS.SUBMITTED;
const grantActionTimeStamp = new Date().getTime();
const grantActionTimeStampKey = (status + "At") as `${typeof status}At`;

try {
await getGrantsDoc(grantId).update({ status, link, [grantActionTimeStampKey]: grantActionTimeStamp });
} catch (error) {
console.error("Error updating the grant status:", error);
throw error;
}
};
8 changes: 8 additions & 0 deletions packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,11 @@ export const EIP_712_TYPES__REVIEW_GRANT = {
{ name: "action", type: "string" },
],
} as const;

export const EIP_712_TYPES__SUBMIT_GRANT = {
Message: [
{ name: "grantId", type: "string" },
{ name: "action", type: "string" },
{ name: "link", type: "string" },
],
} as const;

0 comments on commit bd33702

Please sign in to comment.