diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d4da84c..b7e799d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,7 +57,7 @@ model User { email String? @unique emailVerified DateTime? image String? - position String? + position String @default("") // @deprecated Pending for removal when we expand to multiple universities admin Boolean @default(false) @@ -92,7 +92,7 @@ model Senior { description String StudentIDs String[] @db.ObjectId Students User[] @relation(fields: [StudentIDs], references: [id]) - folder String + folder String @default("") Files File[] ChapterID String @db.ObjectId chapter Chapter @relation(fields: [ChapterID], references: [id]) @@ -126,12 +126,13 @@ model ChapterRequest { } model Chapter { - id String @id @default(auto()) @map("_id") @db.ObjectId - chapterName String - location String - dateCreated DateTime @default(now()) - students User[] - seniors Senior[] + id String @id @default(auto()) @map("_id") @db.ObjectId + chapterName String + location String + chapterFolder String @default("") + dateCreated DateTime @default(now()) + students User[] + seniors Senior[] } model Resource { diff --git a/src/app/api/handle-chapter-request/route.ts b/src/app/api/handle-chapter-request/route.ts index ee815149..c2f50844 100644 --- a/src/app/api/handle-chapter-request/route.ts +++ b/src/app/api/handle-chapter-request/route.ts @@ -3,9 +3,12 @@ import { HandleChapterRequest, HandleChapterRequestResponse, } from "./route.schema"; +import { withSession } from "@server/decorator"; +import { google } from "googleapis"; +import { env } from "process"; import { prisma } from "@server/db/client"; -export const POST = async (request: NextRequest) => { +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 @@ -13,7 +16,7 @@ export const POST = async (request: NextRequest) => { // Validate a proper JSON was passed in as well try { const handleChapterRequest = HandleChapterRequest.safeParse( - await request.json() + await req.json() ); if (!handleChapterRequest.success) { return NextResponse.json( @@ -53,7 +56,7 @@ export const POST = async (request: NextRequest) => { } // If approved, create a new chapter and update approved field of chapter request if (body.approved === true) { - await prisma.chapter.create({ + const createdChapter = await prisma.chapter.create({ data: { chapterName: chapterRequest.university, location: chapterRequest.universityAddress, @@ -67,6 +70,52 @@ export const POST = async (request: NextRequest) => { approved: "APPROVED", }, }); + + const baseFolder = process.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 { access_token, refresh_token } = (await prisma.account.findFirst( + { + where: { + userId: session.user.id, + }, + } + )) ?? { access_token: null }; + const auth = new google.auth.OAuth2({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }); + auth.setCredentials({ + access_token, + refresh_token, + }); + const service = google.drive({ + version: "v3", + auth, + }); + + const file = await ( + service as NonNullable + ).files.create(fileCreateData); + const googleFolderId = (file as any).data.id; + + await prisma.chapter.update({ + where: { + id: createdChapter.id, + }, + data: { + chapterFolder: googleFolderId, + }, + }); + return NextResponse.json( HandleChapterRequestResponse.parse({ code: "SUCCESS", @@ -101,4 +150,4 @@ export const POST = async (request: NextRequest) => { { status: 500 } ); } -}; +}); diff --git a/src/app/api/route.schema.ts b/src/app/api/route.schema.ts index 6db30c7c..c5ec230d 100644 --- a/src/app/api/route.schema.ts +++ b/src/app/api/route.schema.ts @@ -39,4 +39,14 @@ export const invalidFormReponse = invalidFormErrorSchema.parse({ message: "The form is not valid", }); +export const invalidRequestSchema = z.object({ + code: z.literal("INVALID_REQUEST"), + message: z.string(), +}); + +export const invalidRequestResponse = invalidRequestSchema.parse({ + code: "INVALID_REQUEST", + message: "Request body is invalid", +}); + export type IUnauthorizedErrorSchema = z.infer; diff --git a/src/app/api/senior/route.ts b/src/app/api/senior/route.ts index 69a8d750..3efdfd5d 100644 --- a/src/app/api/senior/route.ts +++ b/src/app/api/senior/route.ts @@ -47,11 +47,36 @@ export const POST = withSessionAndRole( }) ); } - const baseFolder = "1MVyWBeKCd1erNe9gkwBf7yz3wGa40g9a"; // TODO: make env variable + + const chapter = await prisma.chapter.findFirst({ + where: { + id: session.user.ChapterID, + }, + }); + if (!chapter) { + return NextResponse.json( + seniorPostResponse.parse({ + code: "UNKNOWN", + message: "Chapter not found", + }), + { status: 400 } + ); + } + + const senior = await prisma.senior.create({ + data: { + firstname: seniorBody.firstname, + lastname: seniorBody.lastname, + location: seniorBody.location, + description: seniorBody.description, + ChapterID: session.user.ChapterID, + StudentIDs: seniorBody.StudentIDs, + }, + }); + + const baseFolder = chapter.chapterFolder; // TODO: make env variable const fileMetadata = { - name: [ - `${seniorBody.firstname}_${seniorBody.lastname}-${randomUUID()}`, - ], + name: [`${seniorBody.firstname}_${seniorBody.lastname}_${senior.id}`], mimeType: "application/vnd.google-apps.folder", parents: [baseFolder], }; @@ -83,14 +108,11 @@ export const POST = withSessionAndRole( ); const googleFolderId = (file as any).data.id; - const senior = await prisma.senior.create({ + await prisma.senior.update({ + where: { + id: senior.id, + }, data: { - firstname: seniorBody.firstname, - lastname: seniorBody.lastname, - location: seniorBody.location, - description: seniorBody.description, - ChapterID: session.user.ChapterID, - StudentIDs: seniorBody.StudentIDs, folder: googleFolderId, }, }); diff --git a/src/app/api/user-request/route.ts b/src/app/api/user-request/route.ts index 0b92423e..bb62ae6c 100644 --- a/src/app/api/user-request/route.ts +++ b/src/app/api/user-request/route.ts @@ -7,6 +7,7 @@ import { } from "./route.schema"; import { prisma } from "@server/db/client"; import { withSession } from "@server/decorator/index"; +import { google } from "googleapis"; export const POST = withSession(async ({ req, session }) => { try { @@ -216,7 +217,7 @@ export const PATCH = withSession(async ({ req, session }) => { approved: "APPROVED", }, }); - await prisma.user.update({ + const user = await prisma.user.update({ where: { id: targetUID, }, @@ -224,7 +225,64 @@ export const PATCH = withSession(async ({ req, session }) => { 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 { access_token, refresh_token } = (await prisma.account.findFirst({ + where: { + userId: session.user.id, + }, + })) ?? { access_token: null }; + const auth = new google.auth.OAuth2({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }); + auth.setCredentials({ + access_token, + refresh_token, + }); + const service = google.drive({ + version: "v3", + auth, + }); + + 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); return NextResponse.json( ManageChapterRequestResponse.parse({ code: "SUCCESS" }) ); diff --git a/src/app/api/user/[uid]/edit-position/route.client.ts b/src/app/api/user/[uid]/edit-position/route.client.ts new file mode 100644 index 00000000..c90058f8 --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.client.ts @@ -0,0 +1,16 @@ +import { TypedRequest } from "@server/type"; +import { z } from "zod"; +import { editPositionRequest, editPositionResponse } from "./route.schema"; + +export const editPosition = async ( + request: TypedRequest>, + uid: string +) => { + const { body, ...options } = request; + const response = await fetch(`/api/user/${uid}/edit-position`, { + method: "PATCH", + body: JSON.stringify(body), + ...options, + }); + return editPositionResponse.parse(await response.json()); +}; diff --git a/src/app/api/user/[uid]/edit-position/route.schema.ts b/src/app/api/user/[uid]/edit-position/route.schema.ts new file mode 100644 index 00000000..38a1b39c --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const editPositionRequest = z.object({ + position: z.string(), +}); + +export const editPositionResponse = z.discriminatedUnion("code", [ + z.object({ + code: z.literal("SUCCESS"), + }), +]); diff --git a/src/app/api/user/[uid]/edit-position/route.ts b/src/app/api/user/[uid]/edit-position/route.ts new file mode 100644 index 00000000..7d4421d0 --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.ts @@ -0,0 +1,38 @@ +import { prisma } from "@server/db/client"; +import { withRole, withSession } from "@server/decorator"; +import { NextResponse } from "next/server"; +import { editPositionRequest, editPositionResponse } from "./route.schema"; +import { + invalidRequestResponse, + unauthorizedErrorResponse, +} from "@api/route.schema"; + +export const PATCH = withSession( + withRole(["CHAPTER_LEADER"], async ({ session, req, params }) => { + const { user: me } = session; + const otherUid: string = params.params.uid; + const other = await prisma.user.findUnique({ + where: { id: otherUid }, + }); + const request = editPositionRequest.safeParse(await req.json()); + + if (other == null || !request.success) { + return NextResponse.json(invalidRequestResponse, { status: 400 }); + } + + if (me.role !== "CHAPTER_LEADER" || me.ChapterID !== other.ChapterID) { + return NextResponse.json(unauthorizedErrorResponse, { status: 401 }); + } + + await prisma.user.update({ + where: { + id: otherUid, + }, + data: { + position: request.data.position, + }, + }); + + return NextResponse.json(editPositionResponse.parse({ code: "SUCCESS" })); + }) +); diff --git a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx index f9dcf72c..03d633ae 100644 --- a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx +++ b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx @@ -1,28 +1,99 @@ "use client"; -import { UserTile } from "@components/TileGrid"; +import { TileEdit, UserTile } from "@components/TileGrid"; import SearchableContainer from "@components/SearchableContainer"; import { User } from "@prisma/client"; +import { useContext, useState } from "react"; +import { UserContext } from "@context/UserProvider"; +import { editPosition } from "@api/user/[uid]/edit-position/route.client"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowUpFromBracket } from "@fortawesome/free-solid-svg-icons"; +import { Dropdown } from "@components/selector"; +import { Popup } from "@components/container"; +import { useRouter } from "next/navigation"; type MembersHomePageProps = { members: User[]; - user: User; }; -const MembersHomePage = ({ members, user }: MembersHomePageProps) => { +const EBOARD_POSITIONS = [ + "Social Coordinator", + "Senior Outreach Coordinator", + "Head of Media", + "Secretary", + "Treasurer", + "Match Coordinator", +].map((position, idx) => ({ id: idx.toString(), position: position })); + +const MembersHomePage = ({ members }: MembersHomePageProps) => { + const { user } = useContext(UserContext); + const [uidToEdit, setUidToEdit] = useState(null); + const [selectedPosition, setSelectedPosition] = useState< + typeof EBOARD_POSITIONS + >([]); + const router = useRouter(); + + const resetAssignment = () => { + setUidToEdit(null); + setSelectedPosition([]); + }; + const displayMembers = (elem: User, index: number) => ( { + e.stopPropagation(); + setUidToEdit(elem.id); + setSelectedPosition( + EBOARD_POSITIONS.filter( + (position) => position.position === elem.position + ) + ); + }, + icon: , + color: "#22555A", + }, + ]} + /> + } /> ); return ( - <> +

{`Members (${members.length})`}

+ {uidToEdit != null && ( + +
Assign to E-board
+ <>{element.position}} + selected={selectedPosition} + setSelected={setSelectedPosition} + onSave={async () => { + await editPosition( + { + body: { position: selectedPosition[0]?.position ?? "" }, + }, + uidToEdit + ); + resetAssignment(); + router.refresh(); + }} + multipleChoice={false} + /> +
+ )} { .includes(filter.toLowerCase()) } /> - +
); }; diff --git a/src/app/private/[uid]/chapter-leader/users/page.tsx b/src/app/private/[uid]/chapter-leader/users/page.tsx index 1165f864..93e30e13 100644 --- a/src/app/private/[uid]/chapter-leader/users/page.tsx +++ b/src/app/private/[uid]/chapter-leader/users/page.tsx @@ -3,22 +3,20 @@ import { prisma } from "@server/db/client"; import MembersHomePage from "./MembersHomePage"; const MembersPage = async ({ params }: { params: { uid: string } }) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: params.uid, - }, - }); - const chapter = await prisma.chapter.findFirstOrThrow({ where: { - id: user.ChapterID ?? "", + students: { + some: { + id: params.uid, + }, + }, }, include: { students: true, }, }); - return ; + return ; }; export default MembersPage; diff --git a/src/app/private/[uid]/user/home/page.tsx b/src/app/private/[uid]/user/home/page.tsx index 87dd88ab..7ceb9441 100644 --- a/src/app/private/[uid]/user/home/page.tsx +++ b/src/app/private/[uid]/user/home/page.tsx @@ -29,14 +29,7 @@ const UserHomePage = async ({ params }: UserHomePageParams) => { }, }); - return ( -
-
- {chapter.chapterName} -
- -
- ); + return ; }; export default UserHomePage; diff --git a/src/components/AddSenior.tsx b/src/components/AddSenior.tsx index e5ee092e..3330e89e 100644 --- a/src/components/AddSenior.tsx +++ b/src/components/AddSenior.tsx @@ -6,7 +6,6 @@ import React, { useState, } from "react"; import Image, { StaticImageData } from "next/legacy/image"; -import cn from "classnames"; import FilterDropdown from "@components/FilterDropdown"; import { Senior, User } from "@prisma/client"; import ImageIcon from "../../public/icons/icon_add_photo.png"; @@ -15,6 +14,7 @@ import { postSenior } from "src/app/api/senior/route.client"; import z from "zod/lib"; import { seniorSchema } from "@server/model"; import { fullName } from "@utils"; +import { Popup } from "./container"; type AddSeniorProps = { seniors: Senior[]; @@ -75,7 +75,7 @@ const StudentSelector = ({ }) => { return (
-
+
Assign students
@@ -84,7 +84,7 @@ const StudentSelector = ({ display={(usr: User) => (
{fullName(usr)} -
+