From bbed79eb39a25dfc54fb2682e6dec47b101304f6 Mon Sep 17 00:00:00 2001 From: DarkPhoenix2704 Date: Sat, 20 Apr 2024 21:33:39 +0530 Subject: [PATCH] feat: add support for create team --- app/api/users/[githubID]/route.ts | 31 ++++ app/components/Button.tsx | 2 +- app/components/Input.tsx | 13 +- app/events/page.tsx | 1 + app/events/results/[eventID]/route.ts | 19 ++- app/events/ui/CurrentEvent.tsx | 39 ++--- app/events/ui/Member.tsx | 74 +++++++++ app/events/ui/modal/CreateTeamModal.tsx | 152 ++++++++++-------- app/events/ui/modal/ProjectModal.tsx | 8 +- app/events/ui/modal/actions.ts | 85 ++++++---- app/ui/modal/ProfileModal.tsx | 6 +- .../migration.sql | 8 + prisma/schema.prisma | 2 +- tailwind.config.ts | 1 + utils/config.ts | 27 ++-- utils/events.ts | 1 - utils/validateRequest.ts | 53 +++++- 17 files changed, 373 insertions(+), 149 deletions(-) create mode 100644 app/api/users/[githubID]/route.ts create mode 100644 app/events/ui/Member.tsx create mode 100644 prisma/migrations/20240420121648_github_unique_calidation/migration.sql diff --git a/app/api/users/[githubID]/route.ts b/app/api/users/[githubID]/route.ts new file mode 100644 index 0000000..b950926 --- /dev/null +++ b/app/api/users/[githubID]/route.ts @@ -0,0 +1,31 @@ +import { db } from "@/utils/db"; +import { validateRequest } from "@/utils/lucia"; +import type { NextRequest } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: { githubID: string } }, +): Promise { + const { session } = await validateRequest(); + + if (!session) { + return new Response(null, { + status: 401, + headers: { + Location: "/auth/login", + }, + }); + } + + const user = await db.user.findUnique({ + where: { + githubId: params.githubID ?? "", + }, + select: { + githubId: true, + }, + }); + return new Response(JSON.stringify(user), { + status: user ? 200 : 404, + }); +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 7a59924..c47efbf 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -47,7 +47,7 @@ export const Button = ({ cy="12" r="10" stroke="currentColor" - stroke-width="4" + strokeWidth="4" /> ) => { +export const Input = forwardRef< + HTMLInputElement, + InputHTMLAttributes +>(({ className, ...rest }, ref) => { return ( ); -}; +}); diff --git a/app/events/page.tsx b/app/events/page.tsx index 2792680..678e132 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -16,6 +16,7 @@ const EventsPage = async ({ searchParams }: SearchParamProps) => { const { user } = await validateRequest(); const currentEvent = await getCurrentEvent(user); + const events = await getEvents(); return ( diff --git a/app/events/results/[eventID]/route.ts b/app/events/results/[eventID]/route.ts index 593c775..3e98822 100644 --- a/app/events/results/[eventID]/route.ts +++ b/app/events/results/[eventID]/route.ts @@ -2,16 +2,27 @@ import { db } from "@/utils/db"; import { EventStatus, ProjectStatus } from "@/utils/types"; import { getResultsParamsSchema, - validateRequest, + validateRequestSchema, } from "@/utils/validateRequest"; import type { NextRequest } from "next/server"; export async function GET( _request: NextRequest, { params }: { params: { eventID: string } }, -): Promise { - const data = validateRequest(getResultsParamsSchema, params); - if (data instanceof Response) return data; +) { + const validation = validateRequestSchema( + getResultsParamsSchema, + params, + true, + ); + + if (validation instanceof Response || !validation.success) { + if (validation instanceof Response) { + return validation; + } + return; + } + const data = validation.data; const event = await db.event.findUnique({ where: { diff --git a/app/events/ui/CurrentEvent.tsx b/app/events/ui/CurrentEvent.tsx index 042b870..4a5b002 100644 --- a/app/events/ui/CurrentEvent.tsx +++ b/app/events/ui/CurrentEvent.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @next/next/no-img-element */ import Link from "next/link"; import Image from "next/image"; import { Calendar } from "lucide-react"; @@ -7,6 +6,7 @@ import { isProfileComplete as isProfileCompleteFn } from "@/utils/user"; import dayjs from "dayjs"; import { redirect } from "next/navigation"; import { CreateTeamModal } from "./modal/CreateTeamModal"; +import { EventStatus, TeamMemberRole } from "@/utils/types"; export const CurrentEvent = ({ user, @@ -28,7 +28,7 @@ export const CurrentEvent = ({ date: Date; location: string; } | null; - registeredTeam: { + team: { id: string; repo: string; eventId: string; @@ -41,11 +41,12 @@ export const CurrentEvent = ({ }) => { const isProfileComplete = isProfileCompleteFn(user); - const { event, registeredTeam } = data; + const { event, team } = data; - const isEditable = registeredTeam - ? registeredTeam.members.some((member) => member.userId === user?.id) - : !!user; + const isEditable = team?.members.some( + (member) => + member.userId === user?.id && member.role === TeamMemberRole.LEADER, + ); if (!event) { redirect("/"); @@ -61,14 +62,14 @@ export const CurrentEvent = ({ } = event; const url = user - ? registeredTeam + ? team ? isEditable - ? status === "REGISTRATION" + ? status === EventStatus.REGISTRATION ? `/events/?update=true&eventId=${event.id}` : `/events/?view=true&eventId=${event.id}` : `/events/?view=true&eventId=${event.id}` : isProfileComplete - ? status === "REGISTRATION" + ? status === EventStatus.REGISTRATION ? `/events/?register=true&eventId=${event.id}` : "" : `/events/?register=true&eventId=${event.id}` @@ -77,9 +78,9 @@ export const CurrentEvent = ({ return (
{/* Modals (you'll likely need to keep these as React components due to state management) */} - {/* {registeredTeam && isOpenUpdateModal && ( + {/* {team && isOpenUpdateModal && ( - {registeredTeam + {team ? "Registered 🎉" - : status === "REGISTRATION" + : status === EventStatus.REGISTRATION ? "Register Now" : "Registration Closed"} @@ -142,22 +143,22 @@ export const CurrentEvent = ({ type="button" className={`bg-white hover:bg-primary active:bg-primary active:ring-2 active:ring-primary transition-all font-medium text-sm px-6 py-3 rounded-md ${ user && - registeredTeam && + team && isEditable && - status === "REGISTRATION" + status === EventStatus.REGISTRATION ? "" : "bg-gray-400 cursor-not-allowed" }`} > {user - ? registeredTeam + ? team ? isEditable - ? status === "REGISTRATION" + ? status === EventStatus.REGISTRATION ? "Update Team" : "View Team" : "View Team" : isProfileComplete - ? status === "REGISTRATION" + ? status === EventStatus.REGISTRATION ? "Register Team" : "Closed" : "Register Team" diff --git a/app/events/ui/Member.tsx b/app/events/ui/Member.tsx new file mode 100644 index 0000000..3adbc2e --- /dev/null +++ b/app/events/ui/Member.tsx @@ -0,0 +1,74 @@ +"use client"; +import React from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { Input } from "@/app/components/Input"; +import { twMerge } from "tailwind-merge"; + +interface MemberProps { + loading: boolean; + isEditable: boolean; +} + +const Member = ({ loading, isEditable }: MemberProps) => { + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: "members", + }); + + return ( +
+
+ + {fields.map((member: Record<"id", string>, index: number) => ( + ( +
+ + !loading && isEditable && remove(index)} + onClick={() => !loading && isEditable && remove(index)} + > + Remove + +
+ )} + /> + ))} +
+ + {fields.length < 3 && ( +
(loading || !isEditable ? null : append(""))} + onKeyDown={() => (loading || !isEditable ? null : append(""))} + > + add + ADD TEAM MEMBER +
+ )} +
+ ); +}; + +export { Member }; diff --git a/app/events/ui/modal/CreateTeamModal.tsx b/app/events/ui/modal/CreateTeamModal.tsx index d71cfd0..23bed34 100644 --- a/app/events/ui/modal/CreateTeamModal.tsx +++ b/app/events/ui/modal/CreateTeamModal.tsx @@ -9,10 +9,11 @@ import { Input } from "@/app/components/Input"; import { X } from "lucide-react"; import { Button } from "@/app/components/Button"; import type { z } from "zod"; -import { useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import createTeam from "./actions"; import { createTeamSchema } from "@/utils/validateRequest"; +import { Member } from "../Member"; type FormData = z.infer; @@ -40,35 +41,45 @@ export const CreateTeamModal = ({ router.push(pathName); }; + const methods = useForm({ + resolver: zodResolver(createTeamSchema), + }); + const { handleSubmit, register, formState: { errors, isSubmitting, isDirty, isValid }, - } = useForm({ - resolver: zodResolver(createTeamSchema), - }); + } = methods; const createTeamWithBindings = createTeam.bind(null, user.id, eventID); - const onSubmit = handleSubmit((data) => { + const onSubmit = async (data: FormData) => { + const isTeamLeadIncluded = data.members.findIndex( + (member) => member.toLowerCase() === user.githubId.toLowerCase(), + ); + + if (isTeamLeadIncluded !== -1) { + data.members.splice(isTeamLeadIncluded, 1); + } + startTransition(() => { createTeamWithBindings(data); }); - }); + }; return ( - + e.preventDefault()} >
- + @@ -81,66 +92,77 @@ export const CreateTeamModal = ({ you'r are currently logged in as{" "} {user?.email} -
-
-
-

Make sure all the members are registered on the platform

-

Project repo can't be changed once submitted

-

You can team up with up to 3 people

-

Team should have at least 1 member

-
-
-
- - -
-
- - - - {

Enter a valid repo Url

} + + +
+
+
+

+ Make sure all the members are registered on the platform +

+

Project repo can't be changed once submitted

+

You can team up with up to 3 people

+

Team should have at least 1 member

+
-
- {/* */} - { -

+

+
+ + + {errors.name && ( +

+ Team Name should be Alpha Numeric & should not contain + any special characters +

+ )} +
+
+ + + + {errors.repo && ( +

Enter a valid repo Url

+ )} +
+ + {errors.members && ( +
User not found or team should have at least 1 member -

- } +
+ )}
-
- -
- -
- + +
+ +
+ +
diff --git a/app/events/ui/modal/ProjectModal.tsx b/app/events/ui/modal/ProjectModal.tsx index af12b83..fe8fac8 100644 --- a/app/events/ui/modal/ProjectModal.tsx +++ b/app/events/ui/modal/ProjectModal.tsx @@ -35,16 +35,16 @@ export const ProjectModal = () => { return ( - + e.preventDefault()} >
- + @@ -80,7 +80,7 @@ export const ProjectModal = () => { )} {error && ( <> - +

Error

{error.message}

diff --git a/app/events/ui/modal/actions.ts b/app/events/ui/modal/actions.ts index be6906e..1d23e0b 100644 --- a/app/events/ui/modal/actions.ts +++ b/app/events/ui/modal/actions.ts @@ -4,10 +4,13 @@ import { sendEmail } from "@/emails"; import { db } from "@/utils/db"; import { revalidatePath } from "next/cache"; -import { schema } from "@/app/events/ui/modal/CreateTeamModal"; +import { + createTeamSchema, + validateRequestSchemaAsync, +} from "@/utils/validateRequest"; import type { z } from "zod"; -export type FormData = z.infer; +export type FormData = z.infer; export default async function createTeam( userId: string, @@ -18,13 +21,44 @@ export default async function createTeam( return "Invalid Event ID"; } - schema.safeParse(formData); + const validation = await validateRequestSchemaAsync( + createTeamSchema, + formData, + false, + ); + + if (validation instanceof Response || !validation.success) { + if (validation instanceof Response) { + return validation; + } + return; + } + + const data = validation.data; + + const admin = await db.user.findUnique({ + where: { + id: userId, + }, + }); + + const userIDs = await db.user.findMany({ + where: { + githubId: { + in: data.members, + }, + }, + select: { + githubId: true, + id: true, + }, + }); - const team = await db.$transaction(async (tx) => { + const [team, members] = await db.$transaction(async (tx) => { const team = await tx.team.create({ data: { - name: formData.name, - repo: formData.repo, + name: data.name, + repo: data.repo, members: { create: { userId: userId, @@ -40,34 +74,31 @@ export default async function createTeam( }, }); await tx.invite.createMany({ - data: formData.members.map((member) => ({ - userId: member, + data: data.members.map((member) => ({ + userId: + userIDs.find( + (user) => user.githubId.toLowerCase() === member.toLowerCase(), + )?.id || "", teamId: team.id, eventId: eventId, role: "MEMBER", })), }); - return team; - }); - const admin = await db.user.findUnique({ - where: { - id: userId, - }, - }); - - const members = await db.invite.findMany({ - where: { - teamId: team.id, - }, - select: { - id: true, - user: { - select: { - email: true, + const members = await db.invite.findMany({ + where: { + teamId: team.id, + }, + select: { + id: true, + user: { + select: { + email: true, + }, }, }, - }, + }); + return [team, members]; }); const mails = []; @@ -102,5 +133,5 @@ export default async function createTeam( await Promise.all(mails); revalidatePath("/events"); - return "Profile updated successfully"; + return "Team created successfully! 🎉"; } diff --git a/app/ui/modal/ProfileModal.tsx b/app/ui/modal/ProfileModal.tsx index 9a65c03..cbbe8cd 100644 --- a/app/ui/modal/ProfileModal.tsx +++ b/app/ui/modal/ProfileModal.tsx @@ -57,17 +57,17 @@ export const ProfileModal = ({ return ( - + e.preventDefault()} >
- + diff --git a/prisma/migrations/20240420121648_github_unique_calidation/migration.sql b/prisma/migrations/20240420121648_github_unique_calidation/migration.sql new file mode 100644 index 0000000..820b21e --- /dev/null +++ b/prisma/migrations/20240420121648_github_unique_calidation/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[githubId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "User_githubId_key" ON "User"("githubId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26e5057..bf5353a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,7 +17,7 @@ model User { mobile String? @db.VarChar avatar String @db.VarChar - githubId String @db.VarChar + githubId String @db.VarChar @unique collegeId String? @db.VarChar college College? @relation(fields: [collegeId], references: [id]) diff --git a/tailwind.config.ts b/tailwind.config.ts index 68cb0cc..513eee9 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -48,6 +48,7 @@ const config: Config = { primary: "#DBF72C", secondary: "#0C0F17", green: "#32ba7c", + red: "#ef4444", }, boxShadow: { primary: "0px 2px 4px rgba(255, 255, 255, 0.15)", diff --git a/utils/config.ts b/utils/config.ts index 9f97209..8332716 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -1,16 +1,17 @@ -import * as zod from "zod"; +import { z } from "zod"; -const envSchema = zod.object({ - DATABASE_URL: zod.string(), - GITHUB_CLIENT_ID: zod.string(), - GITHUB_CLIENT_SECRET: zod.string(), - GITHUB_REDIRECT_URL: zod.string(), - NODE_ENV: zod.string(), - SMTP_HOST: zod.string(), - SMTP_PORT: zod.string(), - SMTP_USER: zod.string(), - SMTP_PASS: zod.string(), - SMTP_FROM: zod.string(), +const envSchema = z.object({ + DATABASE_URL: z.string(), + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), + GITHUB_REDIRECT_URL: z.string(), + NODE_ENV: z.string(), + SMTP_HOST: z.string(), + SMTP_PORT: z.string(), + SMTP_USER: z.string(), + SMTP_PASS: z.string(), + SMTP_FROM: z.string(), + CLIENT_BASE_URL: z.string(), }); -export const env = envSchema.parse(process.env); +export const env = envSchema.safeParse(process.env); diff --git a/utils/events.ts b/utils/events.ts index 47d9437..171a585 100644 --- a/utils/events.ts +++ b/utils/events.ts @@ -46,7 +46,6 @@ export const getCurrentEvent = async (user: User | null) => { }, }, }); - return { event: currentEvent, team: registeredTeam, diff --git a/utils/validateRequest.ts b/utils/validateRequest.ts index a594290..c8aec87 100644 --- a/utils/validateRequest.ts +++ b/utils/validateRequest.ts @@ -1,4 +1,5 @@ import { type ZodFormattedError, z } from "zod"; +import { env } from "./config"; const validateRequestSchema = ( schema: z.ZodType, @@ -35,6 +36,36 @@ const validateRequestSchema = ( }; }; +const validateRequestSchemaAsync = async ( + schema: z.ZodType, + data: unknown, + inResponseFormat = true, +): Promise< + | { success: false; errors: z.ZodFormattedError } + | { success: true; data: Input } + | Response +> => { + const response = await schema.safeParseAsync(data); + + if (!response.success) { + const errors = response.error.format(); + + if (inResponseFormat) { + return new Response(JSON.stringify(errors), { + status: 400, + }); + } + return { + success: false, + errors, + }; + } + return { + success: true, + data: response.data, + }; +}; + const getResultsParamsSchema = z.object({ eventID: z.string().uuid(), }); @@ -62,11 +93,22 @@ const createTeamSchema = z.object({ invalid_type_error: "Enter valid Github repo URL", }) .regex(/^https:\/\/github.com\/[^/]+\/[^/]+$/g), - members: z.array( - z.string({ - invalid_type_error: "Enter valid Github ID", - }), - ), + members: z + .array( + z + .string({ + invalid_type_error: "Enter valid Github ID", + }) + .refine(async (value) => { + console.log(value); + return await fetch(`${env.CLIENT_BASE_URL}/api/users/${value}`).then( + (res) => res.ok, + ); + }), + ) + .min(1) + .max(3) + .nonempty(), }); export { @@ -74,4 +116,5 @@ export { updateProfileSchema, createTeamSchema, validateRequestSchema, + validateRequestSchemaAsync, };