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

Submit completed builds #50

Merged
merged 15 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Loading