diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 587a6a53..bd8c085c 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -34,6 +34,8 @@ jobs: echo SERVICE_ACCOUNT_KEY=${{ secrets.SERVICE_ACCOUNT_KEY }} >> .env echo APP_PORT=${{ secrets.APP_PORT }} >> .env echo APP_FIREBASE_CONFIG=${{ secrets.APP_FIREBASE_CONFIG }} >> .env + echo EMAIL_ADDRESS_1=${{ secrets.EMAIL_ADDRESS_1 }} >> .env + echo PASS_1=${{ secrets.PASS_1 }} >> .env - name: Build Frontend run: cd frontend && npm ci && npm run build - name: Build Backend diff --git a/backend/package-lock.json b/backend/package-lock.json index 6225f288..bd9124f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,11 +20,13 @@ "firebase-functions": "^4.7.0", "mongodb": "^6.3.0", "mongoose": "^8.3.1", + "nodemailer": "^6.9.13", "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/nodemailer": "^6.4.14", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "eslint": "^8.56.0", @@ -1084,6 +1086,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", @@ -4657,6 +4668,14 @@ "node": ">= 6.13.0" } }, + "node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 50ec90f0..03947e01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,11 +30,13 @@ "firebase-functions": "^4.7.0", "mongodb": "^6.3.0", "mongoose": "^8.3.1", + "nodemailer": "^6.9.13", "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/nodemailer": "^6.4.14", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "eslint": "^8.56.0", diff --git a/backend/src/controllers/program.ts b/backend/src/controllers/program.ts index 04b85bba..68b3d20b 100644 --- a/backend/src/controllers/program.ts +++ b/backend/src/controllers/program.ts @@ -17,6 +17,11 @@ export type Program = { color: string; //colorValueHex; hourlyPay: string; sessions: [string[]]; + archived: boolean; +}; + +export type ExistingProgram = Program & { + dateUpdated: string; }; export const createProgram: RequestHandler = async (req, res, next) => { @@ -25,7 +30,10 @@ export const createProgram: RequestHandler = async (req, res, next) => { try { validationErrorParser(errors); - const programForm = await ProgramModel.create(req.body as Program); + const programForm = await ProgramModel.create({ + ...(req.body as Program), + dateUpdated: new Date().toISOString(), + }); res.status(201).json(programForm); } catch (error) { @@ -39,16 +47,27 @@ export const updateProgram: RequestHandler = async (req, res, next) => { validationErrorParser(errors); const programId = req.params.id; - const programData = req.body as Program; + const programData = req.body as ExistingProgram; - const editedProgram = await ProgramModel.findOneAndUpdate({ _id: programId }, programData, { - new: true, - }); + const editedProgram = await ProgramModel.findOneAndUpdate( + { _id: programId }, + { ...programData, archived: false, dateUpdated: new Date().toISOString() }, //stand-in method of un-archiving programs + { + new: true, + }, + ); if (!editedProgram) { return res.status(404).json({ message: "No object in database with provided ID" }); } + // Waitlist all archived students. Making sure to only waitlist Archived students + // will prevent enrollments from being updated every time the program is updated + await EnrollmentModel.updateMany( + { programId: { $eq: programId }, status: { $eq: "Archived" } }, + { $set: { status: "Waitlisted", dateUpdated: Date.now() } }, + ); + res.status(200).json(editedProgram); } catch (error) { next(error); @@ -66,6 +85,32 @@ export const getProgram: RequestHandler = async (req, res, next) => { } res.status(200).json(program); + catch (error) { + next(error); + } +}; + +export const archiveProgram: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { + validationErrorParser(errors); + + const programId = req.params.id; + const program = await ProgramModel.findByIdAndUpdate( + programId, + { $set: { archived: true, dateUpdated: new Date().toISOString() } }, + { new: true }, + ); + if (!program) + return res.status(404).json({ message: "Program with this id not found in database" }); + + //Archive all students + await EnrollmentModel.updateMany( + { programId: { $eq: programId } }, + { $set: { status: "Archived", dateUpdated: Date.now() } }, + ); + + return res.status(200).json(program); } catch (error) { next(error); } diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index e4e9a660..92e619db 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -8,8 +8,10 @@ import { ServiceError } from "../errors/service"; import { ValidationError } from "../errors/validation"; import { Image } from "../models/image"; import UserModel from "../models/user"; +import { sendApprovalEmail, sendDenialEmail } from "../util/email"; import { firebaseAdminAuth } from "../util/firebase"; import { handleImageParsing } from "../util/image"; +import { deleteUserFromFirebase, deleteUserFromMongoDB } from "../util/user"; import validationErrorParser from "../util/validationErrorParser"; import { UserIdRequestBody } from "./types/types"; @@ -49,9 +51,6 @@ export const createUser = async ( name, accountType, email, - // profilePicture default "default" in User constructor - // lastChangedPassword default Date.now() in User constructor - // approvalStatus default false in User constructor }); res.status(201).json(newUser); @@ -63,6 +62,95 @@ export const createUser = async ( return; }; +export const deleteUser = async (req: Request, res: Response, nxt: NextFunction) => { + try { + const { email } = req.params; + + // Find the user by email + const user = await UserModel.findOne({ email }); + if (!user) { + throw new Error("User not found"); + } + + const userId = user._id; // _id is the uid in schema + + // delete user from Firebase and MongoDB + await deleteUserFromFirebase(userId); + await deleteUserFromMongoDB(userId); + + res.status(200).send("User deleted successfully"); + } catch (error) { + console.error("Error deleting user:", error); + nxt(error); + } +}; + +export const getNotApprovedUsers = async (req: Request, res: Response, next: NextFunction) => { + try { + // const notApprovedUsers: User[] = await UserModel.find({ approvalStatus: false }).exec(); + const notApprovedUsers = await UserModel.find({ approvalStatus: false }).exec(); + + res.status(200).json(notApprovedUsers); + } catch (error) { + console.error("Error fetching not-approved users:", error); + next(error); + } +}; + +export const approveUser = async (req: Request, res: Response, nxt: NextFunction) => { + try { + const { email } = req.body; + + const user = await UserModel.findOne({ email }); + if (!user) { + return res.status(404).send("User not found"); + } + + const userId = user._id; + + await UserModel.findByIdAndUpdate(userId, { approvalStatus: true }); + + // await sendApprovalEmail(email); + await sendApprovalEmail(email as string); + + res.status(200).send("User approved successfully"); + } catch (error) { + console.error(error); + nxt(error); + } +}; + +export const denyUser = async (req: Request, res: Response, nxt: NextFunction) => { + console.log("Inside denyUser controller"); + + try { + const { email } = req.body; + + console.log("Email from request body:", email); + + // const user = await UserModel.findOne({ email }); + const user = await UserModel.findOne({ email }); + + if (!user) { + return res.status(404).send("User not found"); + } + + console.log("User object:", user); + + const userId = user._id; + + await UserModel.findByIdAndUpdate(userId, { approvalStatus: false }); + + console.log(email as string); + await sendDenialEmail(email as string); + + res.status(200).send("User denied successfully"); + } catch (error) { + console.error(error); + nxt(error); + } +}; + export const loginUser = async ( req: Request, Record, LoginUserRequestBody>, res: Response, diff --git a/backend/src/models/program.ts b/backend/src/models/program.ts index bc39d1d7..ac4fa3fd 100644 --- a/backend/src/models/program.ts +++ b/backend/src/models/program.ts @@ -8,6 +8,9 @@ const programSchema = new Schema({ color: { type: String, required: true }, hourlyPay: { type: Number, required: true }, sessions: { type: [[String]], required: true }, + archived: { type: Boolean, required: true }, + + dateUpdated: { type: String, required: true }, }); type Program = InferSchemaType; diff --git a/backend/src/routes/program.ts b/backend/src/routes/program.ts index bb5332b1..50132b90 100644 --- a/backend/src/routes/program.ts +++ b/backend/src/routes/program.ts @@ -11,6 +11,7 @@ const router = express.Router(); router.patch("/:id", ProgramValidator.updateProgram, ProgramController.updateProgram); router.post("/create", ProgramValidator.createProgram, ProgramController.createProgram); +router.post("/archive/:id", ProgramController.archiveProgram); router.get("/all", ProgramController.getAllPrograms); router.get("/:id", ProgramController.getProgram); router.get("/enrollments/:id", ProgramController.getProgramEnrollments); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index ff231ac4..62166607 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -9,6 +9,15 @@ const router = express.Router(); router.use(express.json()); router.post("/create", UserValidator.createUser, UserController.createUser); + +router.post("/approve", [verifyAuthToken], UserController.approveUser); + +router.post("/deny", [verifyAuthToken], UserController.denyUser); + +router.delete("/delete/:email", [verifyAuthToken], UserController.deleteUser); + +router.get("/not-approved", [verifyAuthToken], UserController.getNotApprovedUsers); + router.get("/", [verifyAuthToken], UserController.loginUser); router.post("/editPhoto", [verifyAuthToken], UserValidator.editPhoto, UserController.editPhoto); router.get("/getPhoto/:id", [verifyAuthToken], UserController.getPhoto); diff --git a/backend/src/util/email.ts b/backend/src/util/email.ts new file mode 100644 index 00000000..b1b2655e --- /dev/null +++ b/backend/src/util/email.ts @@ -0,0 +1,59 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import nodemailer from "nodemailer"; + +// Create a transporter object using SMTP transport +const transporter = nodemailer.createTransport({ + service: "Gmail", + auth: { + user: process.env.EMAIL_ADDRESS_1, + pass: process.env.PASS_1, + }, +}); + +export const sendApprovalEmail = async (email: string) => { + try { + await transporter.sendMail({ + from: process.env.EMAIL_ADDRESS_1, + to: email, + subject: "Welcome to PIA! Your Account Has Been Approved", + // text: `Hello, + // Thank you for your interest in Plant It Again. + // We are emailing to let you know that your account + // creation request has been approved.` + html: `

Hello,

+

Thank you for your interest in Plant It Again.

+

We are emailing to let you know that your account creation request + has been approved.

`, + }); + console.log("Approval email sent successfully"); + } catch (error) { + console.error("Error sending approval email:", error); + } +}; + +export const sendDenialEmail = async (email: string) => { + console.log("Sending Denial Email"); + try { + await transporter.sendMail({ + from: process.env.EMAIL_ADDRESS_1, + to: email, + subject: "An Update on Your PIA Account Approval Status", + // text: `Hello, + // Thank you for your interest in Plant It Again. + // We are emailing to let you know that your account + // creation request has been denied. + // If you believe this a mistake, + // please contact us through our website` + html: `

Hello,

+

Thank you for your interest in Plant It Again.

+

We are emailing to let you know that your account creation request + has been denied.

+

If you believe this is a mistake, please contact us through our website.

`, + }); + console.log("Denial email sent successfully"); + } catch (error) { + console.error("Error sending denial email:", error); + } +}; diff --git a/backend/src/util/user.ts b/backend/src/util/user.ts new file mode 100644 index 00000000..fca54c2a --- /dev/null +++ b/backend/src/util/user.ts @@ -0,0 +1,13 @@ +import UserModel from "../models/user"; + +import { firebaseAdminAuth } from "./firebase"; + +// delete user from Firebase +export const deleteUserFromFirebase = async (userId: string): Promise => { + await firebaseAdminAuth.deleteUser(userId); +}; + +// delete user from MongoDB +export const deleteUserFromMongoDB = async (userId: string): Promise => { + await UserModel.findByIdAndDelete(userId); +}; diff --git a/frontend/src/api/programs.ts b/frontend/src/api/programs.ts index bc383706..d69a320d 100644 --- a/frontend/src/api/programs.ts +++ b/frontend/src/api/programs.ts @@ -3,7 +3,7 @@ import { CreateProgramRequest } from "../components/ProgramForm/types"; import type { APIResult } from "../api/requests"; -export type Program = CreateProgramRequest & { _id: string }; +export type Program = CreateProgramRequest & { _id: string; dateUpdated: string }; export type Enrollment = { _id: string; @@ -70,3 +70,13 @@ export async function getAllPrograms(): Promise> { return handleAPIError(error); } } + +export async function archiveProgram(program: Program): Promise> { + try { + const response = await POST(`/program/archive/${program._id}`, undefined); + const json = (await response.json()) as Program; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 5372f016..57a27e14 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,4 +1,6 @@ -import { APIResult, GET, PATCH, handleAPIError } from "@/api/requests"; +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { APIResult, DELETE, GET, PATCH, POST, handleAPIError } from "@/api/requests"; export type User = { uid: string; @@ -24,6 +26,72 @@ export const verifyUser = async (firebaseToken: string): Promise } }; +export async function getNotApprovedUsers(firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + console.log(headers); + const response = await GET("/user/not-approved", headers); + const json = (await response.json()) as User[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function approveUser(email: string, firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/user/approve`, { email }, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; // return APIResult with empty data + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to approve user"); + } + } catch (error) { + return { success: false, error: "Failed to approve user" }; + } +} + +export async function denyUser(email: string, firebaseToken: string): Promise> { + console.log("In frontend/src/api/user.ts denyUser()"); + + try { + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/user/deny`, { email }, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; // return APIResult with empty data + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to deny user"); + } + } catch (error) { + return { success: false, error: "Error denying user" }; + } +} + +// delete user by email +export async function deleteUserByEmail( + email: string, + firebaseToken: string, +): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await DELETE(`/user/delete/${encodeURIComponent(email)}`, undefined, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to delete user"); + } + } catch (error) { + return handleAPIError(error); + } +} + type ObjectId = string; // This is a placeholder for the actual ObjectId type const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api"; export async function editPhoto( diff --git a/frontend/src/components/AlertCard.tsx b/frontend/src/components/AlertCard.tsx new file mode 100644 index 00000000..e5224369 --- /dev/null +++ b/frontend/src/components/AlertCard.tsx @@ -0,0 +1,35 @@ +import { MouseEventHandler } from "react"; + +export default function AlertCard({ + message, + open, + onClose, +}: { + message: string; + open: boolean; + onClose: MouseEventHandler; +}) { + if (!open) return <>; + return ( +
+
+
+ + + +

{message}

+
+
+
+ ); +} diff --git a/frontend/src/components/NotificationCard/NotificationCard.tsx b/frontend/src/components/NotificationCard/NotificationCard.tsx index a5380c14..5bdf922d 100644 --- a/frontend/src/components/NotificationCard/NotificationCard.tsx +++ b/frontend/src/components/NotificationCard/NotificationCard.tsx @@ -1,12 +1,19 @@ -import { Button } from "../Button"; - type UserInfo = { name: string; email: string; account_type: string; + onApprove: () => void; + onDeny: () => void; }; -export default function NotificationCard({ name, email, account_type }: UserInfo) { +// export default function NotificationCard({ name, email, account_type }: UserInfo) { +export default function NotificationCard({ + name, + email, + account_type, + onApprove, + onDeny, +}: UserInfo) { return ( <>
-
- +
diff --git a/frontend/src/components/NotificationCard/NotificationTable.tsx b/frontend/src/components/NotificationCard/NotificationTable.tsx index bbeb915b..1a27fdab 100644 --- a/frontend/src/components/NotificationCard/NotificationTable.tsx +++ b/frontend/src/components/NotificationCard/NotificationTable.tsx @@ -1,23 +1,87 @@ +import React, { useEffect, useState } from "react"; + import NotificationCard from "./NotificationCard"; -export default function NotificationTable() { +import { User, approveUser, deleteUserByEmail, denyUser, getNotApprovedUsers } from "@/api/user"; + +type NotificationsProps = { + firebaseToken: string; +}; + +export default function NotificationTable({ firebaseToken }: NotificationsProps) { + const [notApprovedUsers, setNotApprovedUsers] = useState([]); + + useEffect(() => { + const fetchNotApprovedUsers = async () => { + try { + const result = await getNotApprovedUsers(firebaseToken); + if (result.success) { + console.log("notApprovedUsers:", result.data); + + setNotApprovedUsers(result.data); + } else { + console.error("Failed to fetch not-approved users:", result.error); + } + } catch (error) { + console.error("Error fetching not-approved users:", error); + } + }; + + fetchNotApprovedUsers().catch((error) => { + console.error("Error fetching not-approved users:", error); + }); + }, [firebaseToken]); + + const handleApproveUser = async (email: string) => { + // Immediately update the UI (remove corresponding Notification Card) + setNotApprovedUsers((prevUsers) => prevUsers.filter((user) => user.email !== email)); + + try { + const result = await approveUser(email, firebaseToken); + if (!result.success) { + console.error("Failed to approve user:", result.error); + } + } catch (error) { + console.error("Error handling user approval:", error); + } + }; + + const handleDenyUser = async (email: string) => { + try { + // Immediately update the UI (remove corresponding Notification Card) + setNotApprovedUsers((prevUsers) => prevUsers.filter((user) => user.email !== email)); + + const denialResult = await denyUser(email, firebaseToken); + if (!denialResult.success) { + console.error("Failed to deny user:", denialResult.error); + } + + // Delete the user from Firebase and MongoDB + const deletionResult = await deleteUserByEmail(email, firebaseToken); + if (deletionResult.success) { + console.log(`User with email ${email} successfully deleted.`); + } else { + console.error(`Failed to delete user with email ${email}:`, deletionResult.error); + } + } catch (error) { + console.error("Error handling user denial:", error); + } + }; + return (
-
- -
-
-
- -
-
-
- -
+ {notApprovedUsers.map((user) => ( +
+ handleApproveUser(user.email)} + onDeny={() => handleDenyUser(user.email)} + /> +
+ ))}
); } diff --git a/frontend/src/components/ProgramCard.tsx b/frontend/src/components/ProgramCard.tsx index fa7b3e26..04790f58 100644 --- a/frontend/src/components/ProgramCard.tsx +++ b/frontend/src/components/ProgramCard.tsx @@ -18,6 +18,8 @@ export type CardProps = { isAdmin: boolean; className?: string; setPrograms: React.Dispatch>; + setAlertState: React.Dispatch>; + archiveView?: boolean; }; function toggleEdit(id: string) { @@ -44,7 +46,14 @@ function toggleEdit(id: string) { document.body.addEventListener("click", temp, true); } -export function ProgramCard({ program, isAdmin, className, setPrograms }: CardProps) { +export function ProgramCard({ + program, + isAdmin, + className, + setPrograms, + setAlertState, + archiveView = false, +}: CardProps) { const { isTablet } = useWindowSize(); const cardId = "card" + program._id; @@ -111,8 +120,12 @@ export function ProgramCard({ program, isAdmin, className, setPrograms }: CardPr hourlyPay: program.hourlyPay, sessions: program.sessions, //students: program.students, + archived: program.archived, + dateUpdated: program.dateUpdated, }; + const date = new Date(program.dateUpdated); + if (isTablet) { editClass += " top-7 w-12 h-5 text-[10px]"; outerDivClass += " rounded-lg h-36"; @@ -241,6 +254,24 @@ export function ProgramCard({ program, isAdmin, className, setPrograms }: CardPr
+
+
+ +
+
+
+
+

{program.type} Program

+

{program.name}

+
+ {isAdmin && !archiveView && ( +
students{program.students.length} Students

)} */}
+ )} +
+
+
+ {!archiveView && ( + students + )} + {/*program.students.length === 0 &&

No Students

*/} + {/*program.students.length === 1 &&

1 Student

*/} + { + //program.students.length > 1 && ( +

+ {/*program.students.length*/} + { + archiveView + ? "Archived on " + + (date.getMonth() + 1) + + "/" + + date.getDate() + + "/" + + date.getFullYear() + : "0 Students" //<---- Change in the future -------- + } +

+ //) + }
diff --git a/frontend/src/components/ProgramForm/ProgramArchive.tsx b/frontend/src/components/ProgramForm/ProgramArchive.tsx index 480421f8..9fb9c18f 100644 --- a/frontend/src/components/ProgramForm/ProgramArchive.tsx +++ b/frontend/src/components/ProgramForm/ProgramArchive.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { MouseEventHandler, useState } from "react"; import { useForm } from "react-hook-form"; -import { Program } from "../../api/programs"; +import { Program, archiveProgram } from "../../api/programs"; import { Button } from "../Button"; +import { ProgramMap } from "../StudentsTable/types"; import { Textfield } from "../Textfield"; import { Dialog, DialogClose, DialogContentSlide, DialogTrigger } from "../ui/dialog"; @@ -14,6 +15,8 @@ type archiveProps = { setOpenParent: React.Dispatch>; data: Program; isMobile?: boolean; + setPrograms: React.Dispatch>; + setAlertState: React.Dispatch>; }; function ProgramArchiveHeader({ label }: props) { @@ -48,7 +51,13 @@ function ProgramArchiveHeader({ label }: props) { } //Currently no functionality, just a button that closes the form -export default function ProgramArchive({ setOpenParent, data, isMobile = false }: archiveProps) { +export default function ProgramArchive({ + setOpenParent, + data, + isMobile = false, + setPrograms, + setAlertState, +}: archiveProps) { const [openArchive, setOpenArchive] = useState(false); const { register: archiveRegister, @@ -57,21 +66,55 @@ export default function ProgramArchive({ setOpenParent, data, isMobile = false } getValues: getArchiveValue, } = useForm<{ date: string }>(); + const archive: MouseEventHandler = () => { + const date = new Date(getArchiveValue("date")); + const today = new Date(); + if ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ) { + archiveProgram(data) + .then((result) => { + if (result.success) { + console.log(result.data); + archiveReset(); + setOpenArchive(false); + setOpenParent(false); + setAlertState({ open: true, message: result.data.name + " has been archived" }); + setPrograms((prevPrograms: ProgramMap) => { + if (Object.keys(prevPrograms).includes(result.data._id)) + return { ...prevPrograms, [result.data._id]: { ...result.data } }; + else console.log("Program ID does not exist"); + return prevPrograms; + }); + } else { + console.log(result.error); + alert("Unable to archive program: " + result.error); + } + }) + .catch((error) => { + console.log(error); + }); + } + }; + return ( -
+
{!isMobile ? (
{" "} diff --git a/frontend/src/components/ProgramForm/ProgramCancel.tsx b/frontend/src/components/ProgramForm/ProgramCancel.tsx index d3be0663..6103288e 100644 --- a/frontend/src/components/ProgramForm/ProgramCancel.tsx +++ b/frontend/src/components/ProgramForm/ProgramCancel.tsx @@ -22,7 +22,14 @@ export default function ProgramCancel({ isMobile = false, open, setOpen, onCance }} /> ) : ( -
Cancel
+
{ + setOpen(true); + }} + > + Cancel +
)} diff --git a/frontend/src/components/ProgramForm/types.ts b/frontend/src/components/ProgramForm/types.ts index 424a8d7c..114cff0b 100644 --- a/frontend/src/components/ProgramForm/types.ts +++ b/frontend/src/components/ProgramForm/types.ts @@ -6,6 +6,8 @@ export type ProgramData = { color: string; //colorValueHex; hourlyPay: string; sessions: [string[]]; + archived: boolean; + dateUpdated: string; }; export type CreateProgramRequest = { @@ -16,4 +18,5 @@ export type CreateProgramRequest = { color: string; hourlyPay: string; sessions: string[][]; + archived: boolean; }; diff --git a/frontend/src/components/ProgramFormButton.tsx b/frontend/src/components/ProgramFormButton.tsx index b4431a22..4da436ca 100644 --- a/frontend/src/components/ProgramFormButton.tsx +++ b/frontend/src/components/ProgramFormButton.tsx @@ -17,6 +17,7 @@ type BaseProperties = { classname?: string; component: React.JSX.Element; setPrograms: React.Dispatch>; + setAlertState: React.Dispatch>; }; type EditProperties = BaseProperties & { @@ -36,6 +37,7 @@ export default function ProgramFormButton({ component =

Please add a component

, data = null, setPrograms, + setAlertState, classname, }: ProgramFormProperties) { const { register, setValue: setCalendarValue, reset, handleSubmit } = useForm(); @@ -62,6 +64,7 @@ export default function ProgramFormButton({ color: formData.color, hourlyPay: formData.hourlyPay, sessions: programRequestType === "regular" ? sanitizedSessions : [], + archived: formData.archived ? formData.archived : false, }; console.log(`${type} program`, programRequest); @@ -71,6 +74,7 @@ export default function ProgramFormButton({ if (result.success) { setOpenForm(false); console.log(`${type} program`, result.data); + setAlertState({ open: true, message: "Added program " + result.data.name }); setPrograms((prevPrograms: ProgramMap) => { return { ...prevPrograms, [result.data._id]: { ...result.data } }; }); @@ -85,18 +89,22 @@ export default function ProgramFormButton({ }); } if (type === "edit" && data) { - const updatedProgram: Program = { ...programRequest, _id: data._id }; + const updatedProgram: Program = { + ...programRequest, + _id: data._id, + dateUpdated: data.dateUpdated, + }; console.log(`${type} program`, updatedProgram); editProgram(updatedProgram) .then((result) => { if (result.success) { setOpenForm(false); console.log(`${type} program`, result.data); + setAlertState({ open: true, message: "Edited program " + result.data.name }); setPrograms((prevPrograms: ProgramMap) => { if (Object.keys(prevPrograms).includes(result.data._id)) return { ...prevPrograms, [result.data._id]: { ...result.data } }; else console.log("Program ID does not exist"); - alert("Program ID does not exist"); return prevPrograms; }); } else { @@ -122,7 +130,14 @@ export default function ProgramFormButton({ }} >
- {type === "edit" && data && } + {type === "edit" && data && ( + + )}

{type === "add" ? "Add new program" : data?.name} @@ -163,12 +178,19 @@ export default function ProgramFormButton({ setOpen={setOpenCancel} onCancel={() => { setOpenForm(false); + setOpenCancel(false); reset(); }} /> {type === "edit" && data && ( - + )} {type === "add" ? ( diff --git a/frontend/src/components/StudentFormButton.tsx b/frontend/src/components/StudentFormButton.tsx index fc0879a0..870e9cea 100644 --- a/frontend/src/components/StudentFormButton.tsx +++ b/frontend/src/components/StudentFormButton.tsx @@ -116,6 +116,7 @@ export default function StudentFormButton({ setAllStudents((prevStudents: StudentMap) => { return { ...prevStudents, [newStudent._id]: { ...newStudent } }; }); + console.log(newStudent); } else { console.log(result.error); alert("Unable to create student: " + result.error); @@ -143,6 +144,7 @@ export default function StudentFormButton({ return prevStudents; } }); + console.log(editedStudent); } else { console.log(result.error); alert("Unable to edit student: " + result.error); diff --git a/frontend/src/components/StudentsTable/StudentsTable.tsx b/frontend/src/components/StudentsTable/StudentsTable.tsx index a6dae28e..8a23a1f5 100644 --- a/frontend/src/components/StudentsTable/StudentsTable.tsx +++ b/frontend/src/components/StudentsTable/StudentsTable.tsx @@ -44,7 +44,6 @@ export default function StudentsTable() { obj[student._id] = student; return obj; }, {} as StudentMap); - console.log(result.data); setAllStudents(studentsObject); setIsLoading(false); diff --git a/frontend/src/hooks/redirect.tsx b/frontend/src/hooks/redirect.tsx index 58bf0b4b..1dbb315a 100644 --- a/frontend/src/hooks/redirect.tsx +++ b/frontend/src/hooks/redirect.tsx @@ -53,7 +53,8 @@ export const useRedirection = ({ checkShouldRedirect, redirectURL }: UseRedirect */ export const useRedirectToHomeIfSignedIn = () => { useRedirection({ - checkShouldRedirect: ({ firebaseUser, piaUser }) => firebaseUser !== null && piaUser !== null, + checkShouldRedirect: ({ firebaseUser, piaUser }) => + firebaseUser !== null && piaUser !== null && piaUser.approvalStatus, redirectURL: HOME_URL, }); }; diff --git a/frontend/src/pages/create_user_3.tsx b/frontend/src/pages/create_user_3.tsx index a12d98d2..d1eb8007 100644 --- a/frontend/src/pages/create_user_3.tsx +++ b/frontend/src/pages/create_user_3.tsx @@ -17,7 +17,8 @@ export default function CreateUser() { const onBack: SubmitHandler = (data) => { console.log(data); - void router.push("/create_user_2"); + // void router.push("/create_user_2"); + void router.push("/create_user"); }; const isSuccess = createSuccess === "true"; diff --git a/frontend/src/pages/notifications.tsx b/frontend/src/pages/notifications.tsx index e121a081..cde0d050 100644 --- a/frontend/src/pages/notifications.tsx +++ b/frontend/src/pages/notifications.tsx @@ -1,9 +1,32 @@ +import { useContext, useEffect, useState } from "react"; + +import LoadingSpinner from "@/components/LoadingSpinner"; import NotificationTable from "@/components/NotificationCard/NotificationTable"; +import { UserContext } from "@/contexts/user"; import { useRedirectTo404IfNotAdmin, useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; export default function Notifications() { useRedirectToLoginIfNotSignedIn(); useRedirectTo404IfNotAdmin(); + + const { piaUser, firebaseUser } = useContext(UserContext); + const [firebaseToken, setFirebaseToken] = useState(""); + + useEffect(() => { + if (!piaUser || !firebaseUser) return; + + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((error) => { + console.error(error); + }); + } + }, [piaUser, firebaseUser, firebaseToken]); + return (
@@ -11,7 +34,11 @@ export default function Notifications() {
Review information of new account creations below to approve or deny them.{" "}
- + {!piaUser || !firebaseUser ? ( + + ) : ( + + )}
); diff --git a/frontend/src/pages/programs.tsx b/frontend/src/pages/programs.tsx index aa1be446..be77b956 100644 --- a/frontend/src/pages/programs.tsx +++ b/frontend/src/pages/programs.tsx @@ -1,6 +1,7 @@ import Image from "next/image"; -import React, { useContext, useMemo } from "react"; +import React, { useContext, useMemo, useState } from "react"; +import AlertCard from "../components/AlertCard"; import { ProgramCard } from "../components/ProgramCard"; import ProgramFormButton from "../components/ProgramFormButton"; import { useWindowSize } from "../hooks/useWindowSize"; @@ -18,17 +19,27 @@ export default function Programs() { const isTablet = useMemo(() => windowSize.width < 1024, [windowSize.width]); const extraLarge = useMemo(() => windowSize.width >= 2000, [windowSize.width]); + //const [programs, setPrograms] = useState({}); + const [archiveView, setArchiveView] = useState(false); + const [sliderOffset, setSliderOffset] = useState(0); + + const [alertState, setAlertState] = useState<{ open: boolean; message: string }>({ + open: false, + message: "", + }); + const { allPrograms: programs, setAllPrograms: setPrograms, isLoading, } = useContext(ProgramsContext); + const { isAdmin } = useContext(UserContext); let mainClass = "h-full overflow-y-scroll no-scrollbar flex flex-col"; let headerClass = "flex flex-row"; let titleClass = "font-[alternate-gothic]"; - let cardsGridClass = "grid"; + let cardsGridClass = "grid ease-in animate-in fade-in-0"; let cardClass = ""; let emptyClass = "w-fill grow content-center justify-center"; @@ -81,16 +92,62 @@ export default function Programs() { ); + const selectorWrapper = + "relative sm:m-5 flex grid h-6 sm:min-h-12 w-full sm:w-[256px] grid-cols-2 divide-x border-none pointer-events-auto"; + const selectorClass = "flex h-full w-full items-center justify-center border-none"; + const radioClass = "peer flex h-full w-full appearance-none cursor-pointer"; + const selectorLabel = "absolute pointer-events-none sm:text-lg text-sm text-pia_dark_green"; + return (

Programs

{isAdmin && ( - + )} {/* Should be replaced with Add Button when created */}
+ +
+
+ { + setSliderOffset(0); + setArchiveView(false); + }} + /> +
Active
+
+
+ { + setSliderOffset(128); + setArchiveView(true); + }} + /> +
Archived
+
+
+
{isLoading ? ( ) : ( @@ -109,16 +166,32 @@ export default function Programs() {

)} {Object.keys(programs).length > 0 && ( -
- {Object.values(programs).map((program) => ( -
- -
- ))} +
+ {Object.values(programs).map((program) => + program.archived === archiveView || program.archived === undefined ? ( +
+ +
+ ) : ( + <> + ), + )}
)} )} + { + setAlertState({ ...alertState, open: false }); + }} + /> ); }