diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd063652..db81f1e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,7 @@ model File { model ChapterRequest { id String @id @default(auto()) @map("_id") @db.ObjectId + // ChapterRequest can only be in pending or accepted state. We don't use DENIED. approved Approval @default(PENDING) firstName String lastName String @@ -151,6 +152,7 @@ model Email { model UserRequest { id String @id @default(auto()) @map("_id") @db.ObjectId + // @deprecated - We ended up not using this anywhere approved Approval @default(PENDING) uid String @unique @db.ObjectId chapterId String @db.ObjectId diff --git a/src/app/api/handle-chapter-request/route.client.ts b/src/app/api/handle-chapter-request/route.client.ts index 4b8bea4a..f3b30c94 100644 --- a/src/app/api/handle-chapter-request/route.client.ts +++ b/src/app/api/handle-chapter-request/route.client.ts @@ -3,69 +3,11 @@ import { HandleChapterRequest, HandleChapterRequestResponse, } from "./route.schema"; +import { TypedRequest } from "@server/type"; -/** - * Describe the interface of SignInRequest. - */ -type IHandleChapterRequest = z.infer; - -type IHandleChapterRequestResponse = z.infer< - typeof HandleChapterRequestResponse ->; - -/** - * Extract all the values of "code". - */ -type HandleChapterRequestResponseCode = z.infer< - typeof HandleChapterRequestResponse ->["code"]; - -/** - * Extends the parameters of fetch() function to give types to the RequestBody. - */ -interface IRequest extends Omit { - body: IHandleChapterRequest; -} - -const MOCK_SUCCESS: IHandleChapterRequestResponse = { - code: "SUCCESS", - message: "Chapter request successfully handled", -}; - -const MOCK_INVALID_REQUEST: IHandleChapterRequestResponse = { - code: "INVALID_REQUEST", - message: "Invalid API request", -}; - -const MOCK_UNKNOWN: IHandleChapterRequestResponse = { - code: "UNKNOWN", - message: "Unknown error received", -}; - -const MOCK_CHAPTER_REQUEST_NOT_FOUND: IHandleChapterRequestResponse = { - code: "CHAPTER_REQUEST_NOT_FOUND", - message: "A chapter request associated with the given ID does not exist", -}; - -/** - * If "mock" is given as a parameter, the function can return mocked data for a specific case. This - * pattern allows frontend developers to use your API before you finished implementing it! - * - * In addition, using Zod schemas to parse the response will make the input/output well-typed, making the code cleaner. - */ export const handleChapterRequest = async ( - request: IRequest, - mock?: HandleChapterRequestResponseCode + request: TypedRequest> ) => { - if (mock === "SUCCESS") { - return HandleChapterRequestResponse.parse(MOCK_SUCCESS); - } else if (mock === "INVALID_REQUEST") { - return HandleChapterRequestResponse.parse(MOCK_INVALID_REQUEST); - } else if (mock === "UNKNOWN") { - return HandleChapterRequestResponse.parse(MOCK_UNKNOWN); - } else if (mock === "CHAPTER_REQUEST_NOT_FOUND") { - return HandleChapterRequestResponse.parse(MOCK_CHAPTER_REQUEST_NOT_FOUND); - } const { body, ...options } = request; const response = await fetch("/api/handle-chapter-request", { method: "POST", diff --git a/src/app/api/handle-chapter-request/route.schema.ts b/src/app/api/handle-chapter-request/route.schema.ts index 146b8363..b57551e0 100644 --- a/src/app/api/handle-chapter-request/route.schema.ts +++ b/src/app/api/handle-chapter-request/route.schema.ts @@ -14,14 +14,4 @@ export const HandleChapterRequestResponse = z.discriminatedUnion("code", [ code: z.literal("INVALID_REQUEST"), message: z.literal("Invalid API request"), }), - z.object({ - code: z.literal("UNKNOWN"), - message: z.literal("Unknown error received"), - }), - z.object({ - code: z.literal("CHAPTER_REQUEST_NOT_FOUND"), - message: z.literal( - "A chapter request associated with the given ID does not exist" - ), - }), ]); diff --git a/src/app/api/handle-chapter-request/route.ts b/src/app/api/handle-chapter-request/route.ts index aee19033..fc549ac0 100644 --- a/src/app/api/handle-chapter-request/route.ts +++ b/src/app/api/handle-chapter-request/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { HandleChapterRequest, HandleChapterRequestResponse, @@ -9,126 +9,89 @@ import { env } from "@env/server.mjs"; import { prisma } from "@server/db/client"; export const POST = withSession(async ({ req, session }) => { - // Validate the data in the request - // If the data is invalid, return a 400 response - // with a JSON body containing the validation errors + const handleChapterRequest = HandleChapterRequest.safeParse(await req.json()); + if (!handleChapterRequest.success) { + return NextResponse.json( + HandleChapterRequestResponse.parse({ + code: "INVALID_REQUEST", + message: "Invalid API request", + }), + { status: 400 } + ); + } - // Validate a proper JSON was passed in as well - try { - const handleChapterRequest = HandleChapterRequest.safeParse( - await req.json() + const body = handleChapterRequest.data; + const chapterRequest = await prisma.chapterRequest.findFirst({ + where: { + id: body.chapterRequestId, + }, + }); + if (chapterRequest == null || chapterRequest.approved === "APPROVED") { + // If chapter request does not exist, it means that another admin has denied it. + // If chapter request has been approved, do nothing. + return NextResponse.json( + HandleChapterRequestResponse.parse({ + code: "SUCCESS", + message: "Chapter request successfully handled", + }) + ); + } else if (!body.approved) { + await prisma.chapterRequest.delete({ + where: { + id: body.chapterRequestId, + }, + }); + return NextResponse.json( + HandleChapterRequestResponse.parse({ + code: "SUCCESS", + message: "Chapter request successfully handled", + }), + { status: 200 } ); - if (!handleChapterRequest.success) { - return NextResponse.json( - HandleChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "Invalid API request", - }), - { status: 400 } - ); - } else { - const body = handleChapterRequest.data; - // Check if the chapter request even exists - const chapterRequest = await prisma.chapterRequest.findFirst({ - where: { - id: String(body.chapterRequestId), - }, - }); - if (!chapterRequest) { - return NextResponse.json( - HandleChapterRequestResponse.parse({ - code: "CHAPTER_REQUEST_NOT_FOUND", - message: - "A chapter request associated with the given ID does not exist", - }), - { status: 400 } - ); - } - // Check if the chapter request has already been approved/denied - if (chapterRequest.approved !== "PENDING") { - return NextResponse.json( - { - code: "INVALID_REQUEST", - message: "Invalid API request", - }, - { status: 400 } - ); - } - // If approved, create a new chapter and update approved field of chapter request - if (body.approved === true) { - const createdChapter = await prisma.chapter.create({ - data: { - chapterName: chapterRequest.university, - location: chapterRequest.universityAddress, - }, - }); - await prisma.chapterRequest.update({ - where: { - id: body.chapterRequestId, - }, - data: { - approved: "APPROVED", - }, - }); + } else { + // Don't delete chapterRequest on approve to store metadata + await prisma.chapterRequest.update({ + where: { id: body.chapterRequestId }, + data: { approved: "APPROVED" }, + }); + + const createdChapter = await prisma.chapter.create({ + data: { + chapterName: chapterRequest.university, + location: chapterRequest.universityAddress, + }, + }); - const baseFolder = env.GOOGLE_BASEFOLDER; // TODO: make env variable - const fileMetadata = { - name: [`${chapterRequest.university}-${createdChapter.id}`], - mimeType: "application/vnd.google-apps.folder", - parents: [baseFolder], - }; - const fileCreateData = { - resource: fileMetadata, - fields: "id", - }; + const baseFolder = env.GOOGLE_BASEFOLDER; + const fileMetadata = { + name: [`${chapterRequest.university}-${createdChapter.id}`], + mimeType: "application/vnd.google-apps.folder", + parents: [baseFolder], + }; + const fileCreateData = { + resource: fileMetadata, + fields: "id", + }; - const service = await createDriveService(session.user.id); - const file = await ( - service as NonNullable - ).files.create(fileCreateData); - const googleFolderId = (file as any).data.id; + const service = await createDriveService(session.user.id); + const file = await service.files.create(fileCreateData); + const googleFolderId = file.data.id as string; - await prisma.chapter.update({ - where: { - id: createdChapter.id, - }, - data: { - chapterFolder: googleFolderId, - }, - }); + await prisma.chapter.update({ + where: { + id: createdChapter.id, + }, + data: { + chapterFolder: googleFolderId, + }, + }); - return NextResponse.json( - HandleChapterRequestResponse.parse({ - code: "SUCCESS", - message: "Chapter request successfully handled", - }), - { status: 200 } - ); - } - // If denied just updated approved field of chapter request - await prisma.chapterRequest.update({ - where: { - id: body.chapterRequestId, - }, - data: { - approved: "DENIED", - }, - }); - return NextResponse.json( - HandleChapterRequestResponse.parse({ - code: "SUCCESS", - message: "Chapter request successfully handled", - }), - { status: 200 } - ); - } - } catch { return NextResponse.json( HandleChapterRequestResponse.parse({ - code: "UNKNOWN", - message: "Unknown error received", + code: "SUCCESS", + message: "Chapter request successfully handled", }), - { status: 500 } + { status: 200 } ); } }); diff --git a/src/app/api/user-request/route.client.ts b/src/app/api/user-request/route.client.ts index 81abb51a..2c3cd05b 100644 --- a/src/app/api/user-request/route.client.ts +++ b/src/app/api/user-request/route.client.ts @@ -5,17 +5,12 @@ import { JoinChapterRequestResponse, ManageChapterRequestResponse, } from "./route.schema"; +import { TypedRequest } from "@server/type"; -interface IJoinChapterRequest extends Omit { - body: z.infer; -} - -interface IManageChapterRequest extends Omit { - body: z.infer; -} +type AcceptOrDenyRequest = TypedRequest>; export const handleJoinChapterRequest = async ( - request: IJoinChapterRequest + request: TypedRequest> ) => { const { body, ...options } = request; const response = await fetch("/api/user-request", { @@ -28,10 +23,9 @@ export const handleJoinChapterRequest = async ( }; export const handleManageChapterRequest = async ( - request: IManageChapterRequest + request: AcceptOrDenyRequest ) => { const { body, ...options } = request; - console.log("body", body); const response = await fetch("/api/user-request", { method: "DELETE", body: JSON.stringify(body), @@ -42,7 +36,7 @@ export const handleManageChapterRequest = async ( }; export const handleAcceptChapterRequest = async ( - request: IManageChapterRequest + request: AcceptOrDenyRequest ) => { const { body, ...options } = request; const response = await fetch("/api/user-request", { diff --git a/src/app/api/user-request/route.ts b/src/app/api/user-request/route.ts index 23fbb16d..05d7712d 100644 --- a/src/app/api/user-request/route.ts +++ b/src/app/api/user-request/route.ts @@ -77,203 +77,146 @@ export const POST = withSession(async ({ req, session }) => { }); export const DELETE = withSession(async ({ req, session }) => { - try { - const denyChapterReq = ManageChapterRequest.safeParse(await req.json()); + const denyChapterReq = ManageChapterRequest.safeParse(await req.json()); - if (!denyChapterReq.success) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "Invalid request body", - }), - { status: 400 } - ); - } + if (!denyChapterReq.success) { + return NextResponse.json( + ManageChapterRequestResponse.parse({ + code: "INVALID_REQUEST", + message: "Invalid request body", + }), + { status: 400 } + ); + } - const targetUID = denyChapterReq.data.userId; - const target = await prisma.user.findFirst({ - where: { - id: targetUID, - }, - }); - if (target == null) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "User doesn't exist", - }), - { status: 400 } - ); - } + const targetUID = denyChapterReq.data.userId; + const joinChapterRequest = await prisma.userRequest.findFirst({ + where: { + uid: targetUID, + }, + }); - const joinChapterRequest = await prisma.userRequest.findFirst({ - where: { - uid: session.user.id, - }, - }); + if (joinChapterRequest == null) { + // An admin have denied or accepted the user + return NextResponse.json( + ManageChapterRequestResponse.parse({ + code: "SUCCESS", + }) + ); + } - if (joinChapterRequest == null) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "User doesn't have any active request", - }), - { status: 400 } - ); - } + const canApprove = + session.user.role === "ADMIN" || + (session.user.role === "CHAPTER_LEADER" && + session.user.ChapterID === joinChapterRequest.chapterId) || + session.user.id === targetUID; - const canApprove = - session.user.role === "ADMIN" || - (session.user.role === "CHAPTER_LEADER" && - session.user.ChapterID === joinChapterRequest.chapterId) || - session.user.id === targetUID; + if (!canApprove) { + return NextResponse.json( + ManageChapterRequestResponse.parse({ + code: "UNAUTHORIZED_REQUEST", + message: "User doesn't have permission to deny request", + }), + { status: 400 } + ); + } - if (!canApprove) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "UNAUTHORIZED_REQUEST", - message: "User doesn't have permission to deny request", - }), - { status: 400 } - ); - } + await prisma.userRequest.delete({ + where: { + uid: targetUID, + }, + }); - await prisma.userRequest.delete({ - where: { - uid: targetUID, - }, - }); + return NextResponse.json( + ManageChapterRequestResponse.parse({ code: "SUCCESS" }) + ); +}); +export const PATCH = withSession(async ({ req, session }) => { + const approveChapterReq = ManageChapterRequest.safeParse(await req.json()); + if (!approveChapterReq.success) { return NextResponse.json( - ManageChapterRequestResponse.parse({ code: "SUCCESS" }) + ManageChapterRequestResponse.parse({ + code: "INVALID_REQUEST", + message: "Invalid request body", + }), + { status: 400 } ); - } catch (e: any) { + } + const targetUID = approveChapterReq.data.userId; + const chapterRequest = await prisma.userRequest.findUnique({ + where: { uid: targetUID }, + include: { user: true }, + }); + if (chapterRequest == null || chapterRequest.user.ChapterID != null) { + // If chapterRequest doesn't exist, another admin has denied the user / chapter has been deleted / user has been accepted return NextResponse.json( - ManageChapterRequestResponse.parse({ code: "UNKNOWN" }), - { status: 500 } + ManageChapterRequestResponse.parse({ code: "SUCCESS" }) ); } -}); -export const PATCH = withSession(async ({ req, session }) => { - try { - const approveChapterReq = ManageChapterRequest.safeParse(await req.json()); - if (!approveChapterReq.success) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "Invalid request body", - }), - { status: 400 } - ); - } - const targetUID = approveChapterReq.data.userId; - const target = await prisma.user.findFirst({ - where: { - id: targetUID, - }, - }); - if (target == null) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "User doesn't exist", - }), - { status: 400 } - ); - } - const approveChapterRequest = await prisma.userRequest.findFirst({ - where: { - uid: targetUID, - }, - }); - if (approveChapterRequest == null) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "User doesn't have any active request", - }), - { status: 400 } - ); - } - const canApprove = - session.user.role === "ADMIN" || - (session.user.role === "CHAPTER_LEADER" && - session.user.ChapterID === approveChapterRequest.chapterId); - if (!canApprove) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "UNAUTHORIZED_REQUEST", - message: "User doesn't have permission to approve request", - }), - { status: 400 } - ); - } - await prisma.userRequest.update({ - where: { - uid: targetUID, - }, - data: { - approved: "APPROVED", - }, - }); - const user = await prisma.user.update({ - where: { - id: targetUID, - }, - data: { - ChapterID: approveChapterRequest.chapterId, - }, - }); - const chapter = await prisma.chapter.findFirst({ - where: { - id: approveChapterRequest.chapterId, - }, - }); - - if (chapter == null || user == null || user.email == null) { - return NextResponse.json( - ManageChapterRequestResponse.parse({ - code: "INVALID_REQUEST", - message: "Chapter or user (or email) doesn't exist", - }), - { status: 400 } - ); - } - - const folderId = chapter.chapterFolder; - - // Next, share the folder with the user that is accepted - const shareFolder = async (folderId: string, userEmail: string) => { - const service = await createDriveService(session.user.id); - - try { - // Define the permission - const permission = { - type: "user", - role: "writer", // Change role as per your requirement - emailAddress: userEmail, - }; - - // Share the folder - await service.permissions.create({ - fileId: folderId, - requestBody: permission, - }); - - console.log("Folder shared successfully!"); - } catch (error) { - console.error("Error sharing folder:", error); - } - }; - await shareFolder(folderId, user.email); + const canApprove = + session.user.role === "ADMIN" || + (session.user.role === "CHAPTER_LEADER" && + session.user.ChapterID === chapterRequest.chapterId); + if (!canApprove) { return NextResponse.json( - ManageChapterRequestResponse.parse({ code: "SUCCESS" }) + ManageChapterRequestResponse.parse({ + code: "UNAUTHORIZED_REQUEST", + message: "User doesn't have permission to approve request", + }), + { status: 400 } ); - } catch (e: any) { + } + await prisma.userRequest.delete({ + where: { + uid: targetUID, + }, + }); + + const chapter = await prisma.chapter.findFirst({ + where: { + id: chapterRequest.chapterId, + }, + }); + + if (chapter == null) { return NextResponse.json( - ManageChapterRequestResponse.parse({ code: "UNKNOWN" }), - { status: 500 } + ManageChapterRequestResponse.parse({ + code: "INVALID_REQUEST", + message: "Chapter or user (or email) doesn't exist", + }), + { status: 400 } ); } + + const folderId = chapter.chapterFolder; + + // Next, share the folder with the user that is accepted + const shareFolder = async (folderId: string, userEmail: string) => { + const service = await createDriveService(session.user.id); + // Define the permission + const permission = { + type: "user", + role: "writer", // Change role as per your requirement + emailAddress: userEmail, + }; + + // Share the folder + await service.permissions.create({ + fileId: folderId, + requestBody: permission, + }); + }; + // Since we use Google login, they must have an email + await shareFolder(folderId, chapterRequest.user.email ?? ""); + // We update the chapter ID second to allow the user to rejoin in the case that shareFolder fails midway + await prisma.user.update({ + where: { id: chapterRequest.uid }, + data: { ChapterID: chapterRequest.chapterId }, + }); + + return NextResponse.json( + ManageChapterRequestResponse.parse({ code: "SUCCESS" }) + ); }); diff --git a/src/components/ChapterRequest.tsx b/src/components/ChapterRequest.tsx index 9ba730b8..7634aeee 100644 --- a/src/components/ChapterRequest.tsx +++ b/src/components/ChapterRequest.tsx @@ -1,5 +1,4 @@ "use client"; - import { handleChapterRequest } from "src/app/api/handle-chapter-request/route.client"; import { useRouter } from "next/navigation"; import { ChapterRequest } from "@prisma/client"; diff --git a/src/components/DisplayChapterInfo.tsx b/src/components/DisplayChapterInfo.tsx index 44af854e..8a18d0d1 100644 --- a/src/components/DisplayChapterInfo.tsx +++ b/src/components/DisplayChapterInfo.tsx @@ -123,7 +123,7 @@ const DisplayChapterInfo = ({ ); })} diff --git a/src/components/PendingCard.tsx b/src/components/PendingCard.tsx index 5e0f6403..470510ba 100644 --- a/src/components/PendingCard.tsx +++ b/src/components/PendingCard.tsx @@ -9,6 +9,9 @@ import { handleManageChapterRequest, handleAcceptChapterRequest, } from "@api/user-request/route.client"; +import { useApiThrottle } from "@hooks"; +import { useRouter } from "next/navigation"; +import { Spinner } from "./skeleton"; interface IPendingCard { name: string; @@ -16,6 +19,25 @@ interface IPendingCard { } const PendingCard = (props: IPendingCard) => { + const router = useRouter(); + const { + fetching: acceptChapterRequestFetching, + fn: throttleAcceptChapterRequest, + } = useApiThrottle({ + fn: handleAcceptChapterRequest, + callback: () => router.refresh(), + }); + const { + fetching: manageChapterRequestFetching, + fn: throttleManageChapterRequest, + } = useApiThrottle({ + fn: handleManageChapterRequest, + callback: () => router.refresh(), + }); + + const isFetching = + acceptChapterRequestFetching || manageChapterRequestFetching; + return (
@@ -25,34 +47,28 @@ const PendingCard = (props: IPendingCard) => {
- - { - handleManageChapterRequest({ - body: { - userId: props.uid, - }, - }).then(() => { - window.location.reload(); - }); - }} - /> + {!isFetching ? ( + <> + + + throttleManageChapterRequest({ body: { userId: props.uid } }) + } + /> + + ) : ( + + )}
); diff --git a/src/components/TileGrid/InfoTile.tsx b/src/components/TileGrid/InfoTile.tsx index 33421776..adfbdfcb 100644 --- a/src/components/TileGrid/InfoTile.tsx +++ b/src/components/TileGrid/InfoTile.tsx @@ -27,12 +27,6 @@ interface InfoTileProps { const InfoTile = (params: InfoTileProps) => { const { title, information, topRightButton, moreInformation, href } = params; - const [shouldShowMore, setShouldShowMore] = React.useState(false); - - const onToggleShouldShowMore = () => { - setShouldShowMore(!shouldShowMore); - }; - return (
@@ -55,15 +49,17 @@ const InfoTile = (params: InfoTileProps) => {
))}
- - {!shouldShowMore ? "Show more" : "Show less"} -

- } - > - {moreInformation} -
+ {moreInformation ? ( + + Show more +

+ } + > + {moreInformation} +
+ ) : null} ); };