Skip to content
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
92 changes: 92 additions & 0 deletions packages/nextjs/app/api/challenges/[challengeId]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { createEvent } from "~~/services/database/repositories/events";
import { upsertUserChallenge } from "~~/services/database/repositories/userChallenges";
import { findUserByAddress } from "~~/services/database/repositories/users";
import { isValidEIP712ChallengeSubmitSignature } from "~~/services/eip712/challenge";

export type ChallengeSubmitPayload = {
userAddress: string;
frontendUrl: string;
contractUrl: string;
signature: `0x${string}`;
};

export type AutogradingResult = {
success: boolean;
feedback: string;
};

// TODO: Remove this and make request to actual autograder
async function mockAutograding(contractUrl: string): Promise<AutogradingResult> {
console.log("Mock autograding for contract:", contractUrl);
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
feedback: "All tests passed successfully! Great work!",
};
}

export async function POST(req: NextRequest, { params }: { params: { challengeId: string } }) {
try {
const challengeId = params.challengeId;
const { userAddress, frontendUrl, contractUrl, signature } = (await req.json()) as ChallengeSubmitPayload;

if (!userAddress || !frontendUrl || !contractUrl || !signature) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}

const isValidSignature = await isValidEIP712ChallengeSubmitSignature({
address: userAddress,
signature,
challengeId,
frontendUrl,
contractUrl,
});

if (!isValidSignature) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

const user = await findUserByAddress(userAddress);
if (user.length === 0) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

// TODO: Create challenge submission only when autograder is turned on for that challenge
/* await createEvent({
eventType: "challenge.submit",
userAddress: lowerCasedUserAddress,
challengeCode: challengeId,
}); */

// TODO: Make request to actual autograder
// TODO: Think if we want to wait the autograder to finish or just return the result immediately
// - Check Vercel timeout limit and see if we return and have the function idle until the result is ready
// An alternative is have and endpoint that receives the autograder result and update the database
const gradingResult = await mockAutograding(contractUrl);

await upsertUserChallenge({
userAddress: userAddress,
challengeCode: challengeId,
frontendUrl,
contractUrl,
reviewAction: gradingResult.success ? "ACCEPTED" : "REJECTED",
reviewComment: gradingResult.feedback,
});

await createEvent({
eventType: "challenge.autograde",
userAddress: userAddress,
challengeCode: challengeId,
});

return NextResponse.json({
success: true,
message: "Challenge submitted and graded successfully",
autoGradingResult: gradingResult,
});
} catch (error) {
console.error("Error submitting challenge:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion packages/nextjs/app/api/users/[address]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function GET(_req: Request, { params }: { params: { address: string
return NextResponse.json({ error: "Address is required" }, { status: 400 });
}

const users = await findUserByAddress(address.toLowerCase());
const users = await findUserByAddress(address);
if (users.length === 0) {
return NextResponse.json({ user: null }, { status: 404 });
}
Expand Down
7 changes: 3 additions & 4 deletions packages/nextjs/app/api/users/register/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createUser, isUserRegistered } from "~~/services/database/repositories/users";
import { isValidEIP712UserRegisterSignature } from "~~/utils/eip712/register";
import { isValidEIP712UserRegisterSignature } from "~~/services/eip712/register";

type RegisterPayload = {
address: string;
Expand All @@ -10,13 +10,12 @@ type RegisterPayload = {
export async function POST(req: Request) {
try {
const { address, signature } = (await req.json()) as RegisterPayload;
console.log("registerUser", address, signature);

if (!address || !signature) {
return NextResponse.json({ error: "Address and signature are required" }, { status: 400 });
}

const userExist = await isUserRegistered(address.toLowerCase());
const userExist = await isUserRegistered(address);

if (userExist) {
return NextResponse.json({ error: "User already registered" }, { status: 401 });
Expand All @@ -28,7 +27,7 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

const users = await createUser({ id: address.toLowerCase() });
const users = await createUser({ id: address });

return NextResponse.json({ user: users[0] }, { status: 200 });
} catch (error) {
Expand Down
9 changes: 9 additions & 0 deletions packages/nextjs/app/builders/[address]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UserChallengesTable } from "../_component/UserChallengesTable";
import { findUserChallengesByAddress } from "~~/services/database/repositories/userChallenges";

export default async function BuilderPage({ params }: { params: { address: string } }) {
const { address: userAddress } = params;
const challenges = await findUserChallengesByAddress(userAddress);

return <UserChallengesTable challenges={challenges} />;
}
72 changes: 72 additions & 0 deletions packages/nextjs/app/builders/_component/UserChallengesTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { UserChallenges } from "~~/services/database/repositories/userChallenges";

export const UserChallengesTable = ({ challenges }: { challenges: UserChallenges }) => {
return (
<div className="flex flex-col gap-8 py-8 px-4 lg:px-8">
<div>
<h1 className="text-4xl font-bold mb-0">Challenges</h1>
</div>
<div className="w-full">
<table className="table table-zebra bg-base-100">
<thead>
<tr className="text-sm">
<th className="bg-primary">NAME</th>
<th className="bg-primary">CONTRACT</th>
<th className="bg-primary">LIVE DEMO</th>
<th className="bg-primary">SUBMITED AT</th>
<th className="bg-primary">STATUS</th>
<th className="bg-primary"></th>
</tr>
</thead>
<tbody>
{challenges.map(challenge => (
<tr key={challenge.challengeCode} className="hover">
<td>🏃‍♂️ Challenge {challenge.challengeCode}</td>
<td>
{challenge.contractUrl ? (
<a href={challenge.contractUrl} target="_blank" rel="noopener noreferrer" className="link">
Code
</a>
) : (
"-"
)}
</td>
<td>
{challenge.frontendUrl ? (
<a href={challenge.frontendUrl} target="_blank" rel="noopener noreferrer" className="link">
Demo
</a>
) : (
"-"
)}
</td>
<td>{challenge.submittedTimestamp ? challenge.submittedTimestamp.toLocaleString() : "-"}</td>
<td>
<span
className={`badge ${
challenge.reviewAction === "ACCEPTED"
? "badge-success"
: challenge.reviewAction === "REJECTED"
? "badge-error"
: "badge-warning"
}`}
>
{challenge.reviewAction?.toLowerCase() || "pending"}
</span>
</td>
<td>
{challenge.reviewComment && (
<div className="tooltip" data-tip={challenge.reviewComment}>
<QuestionMarkCircleIcon className="h-4 w-4 cursor-help" />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import { useRef } from "react";
import { SubmitChallengeModal } from "./SubmitChallengeModal";
import { useAccount } from "wagmi";
import { useUser } from "~~/hooks/useUser";

export const SubmitChallengeButton = ({ challengeId }: { challengeId: string }) => {
const submitChallengeModalRef = useRef<HTMLDialogElement>(null);
const { address: connectedAddress } = useAccount();

const { data: user, isLoading: isLoadingUser } = useUser(connectedAddress);
return (
<>
<button
className="btn btn-lg btn-primary mt-2 fixed bottom-8 inset-x-0 mx-auto w-fit"
disabled={!user || isLoadingUser}
onClick={() => submitChallengeModalRef && submitChallengeModalRef.current?.showModal()}
>
Submit
</button>
<SubmitChallengeModal
challengeId={challengeId}
ref={submitChallengeModalRef}
closeModal={() => submitChallengeModalRef.current?.close()}
/>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { forwardRef, useState } from "react";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { InputBase } from "~~/components/scaffold-eth";
import { useSubmitChallenge } from "~~/hooks/useSubmitChallenge";

type SubmitChallengeModalProps = {
challengeId: string;
closeModal: () => void;
};

export const SubmitChallengeModal = forwardRef<HTMLDialogElement, SubmitChallengeModalProps>(
({ closeModal, challengeId }, ref) => {
const [frontendUrl, setFrontendUrl] = useState("");
const [contractUrl, setContractUrl] = useState("");

const { submitChallenge, isPending } = useSubmitChallenge({
onSuccess: closeModal,
});

return (
<dialog ref={ref} className="modal">
<div className="modal-box flex flex-col space-y-3">
<form method="dialog" className="bg-secondary -mx-6 -mt-6 px-6 py-4 flex items-center justify-between">
<div className="flex justify-between items-center">
<p className="font-bold text-xl m-0">Submit Challenge</p>
</div>
<button onClick={closeModal} className="btn btn-sm btn-circle btn-ghost text-xl h-auto">
</button>
</form>

<h1 className="text-2xl font-semibold ml-2">{challengeId}</h1>

<div className="flex flex-col space-y-5">
<div className="flex flex-col gap-1.5 w-full">
<div className="flex items-base ml-2">
<span className="text-sm font-medium mr-2 leading-none">Deployed URL</span>
<div className="tooltip" data-tip="Your deployed challenge URL on vercel">
<QuestionMarkCircleIcon className="h-4 w-4" />
</div>
</div>
<InputBase
placeholder="https://your-site.vercel.app"
value={frontendUrl}
onChange={e => setFrontendUrl(e)}
/>
</div>

<div className="flex flex-col gap-1.5 w-full">
<div className="flex items-base ml-2">
<span className="text-sm font-medium mr-2 leading-none">Etherscan URL</span>
<div className="tooltip" data-tip="Your verfied contract URL on etherscan">
<QuestionMarkCircleIcon className="h-4 w-4" />
</div>
</div>
<InputBase
placeholder="https://sepolia.etherscan.io/address/**YourContractAddress**"
value={contractUrl}
onChange={e => setContractUrl(e)}
/>
</div>

<div className="modal-action">
<button
className="btn btn-primary self-center"
disabled={!Boolean(frontendUrl && contractUrl) || isPending}
onClick={() => submitChallenge({ challengeId, frontendUrl, contractUrl })}
>
{isPending ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Submitting...
</>
) : (
"Submit Challenge"
)}
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={closeModal}>close</button>
</form>
</dialog>
);
},
);

SubmitChallengeModal.displayName = "SubmitChallengeModal";
4 changes: 3 additions & 1 deletion packages/nextjs/app/challenge/[challengeId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SubmitChallengeButton } from "./_components/SubmitChallengeButton";
import { MDXRemote } from "next-mdx-remote/rsc";
import { findChallengeById, getAllChallenges } from "~~/services/database/repositories/challenges";
import { fetchGithubReadme } from "~~/services/github";
Expand All @@ -24,14 +25,15 @@ export default async function ChallengePage({ params }: { params: { challengeId:
const challengeReadme = await fetchGithubReadme(challenge.github);

return (
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-4 p-4 relative">
{challengeReadme ? (
<div className="prose dark:prose-invert max-w-none">
<MDXRemote source={challengeReadme} />
</div>
) : (
<div>Failed to load challenge content</div>
)}
<SubmitChallengeButton challengeId={challenge.id} />
</div>
);
}
Loading