From 201899aa4a19fe89694ae1ee5426594ff59dcc10 Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Mon, 16 Jan 2023 02:33:54 -0800 Subject: [PATCH] Parent Profile + Linking Students (#123) --- .gitignore | 1 + __tests__/parents.test.ts | 90 +++++ __tests__/users.test.ts | 6 +- components/Login/LoginPositionInput.tsx | 15 +- components/Navbar/Navbar.tsx | 8 +- .../ParentProfile/AddStudentModal.module.css | 51 +++ .../Profile/ParentProfile/AddStudentModal.tsx | 54 +++ .../ConnectStudentProfile.module.css | 26 ++ .../ParentProfile/ConnectStudentProfile.tsx | 56 ++++ .../ConnectedStudentDisplay.module.css | 47 +++ .../ParentProfile/ConnectedStudentDisplay.tsx | 43 +++ .../ParentEditProfilePanel.module.css | 74 +++++ .../ParentProfile/ParentEditProfilePanel.tsx | 308 ++++++++++++++++++ .../ParentProfileView.module.css | 49 +++ .../ParentProfile/ParentProfileView.tsx | 144 ++++++++ .../ParentProfile/ProfileInput.module.css | 9 + .../Profile/ParentProfile/ProfileInput.tsx | 55 ++++ .../ParentProfile/ProfilePicture.module.css | 39 +++ .../Profile/ParentProfile/ProfilePicture.tsx | 129 ++++++++ context/AuthContext.tsx | 9 +- context/LeagueAPI.ts | 41 ++- lib/database/parents.ts | 36 ++ lib/database/users.ts | 19 +- migrations/0012_create-parents-table.sql | 11 + models/Attendance.ts | 18 - models/AttendanceTypes.ts | 22 -- models/CalendarEvent.ts | 18 - models/ClassEvent.ts | 24 -- models/CreateAttendance.ts | 12 - models/CreateClass.ts | 22 -- models/CreateClassEvent.ts | 30 -- models/CreateItem.ts | 12 - models/CreateModule.ts | 14 - models/CreateUser.ts | 18 - models/Image.ts | 28 -- models/Item.ts | 16 - models/Module.ts | 16 - models/Roles.ts | 19 -- models/SingleUserAttendance.ts | 16 - models/Student.ts | 53 --- models/UpdateClass.ts | 20 -- models/UpdateImage.ts | 12 - models/UpdateModule.ts | 12 - models/UpdateUser.ts | 40 --- models/User.ts | 42 --- models/index.ts | 30 -- models/openapi/models.swagger.yaml | 19 ++ pages/api/parents/[id]/student/index.ts | 76 +++++ pages/profile/index.tsx | 16 +- 49 files changed, 1403 insertions(+), 522 deletions(-) create mode 100644 __tests__/parents.test.ts create mode 100644 components/Profile/ParentProfile/AddStudentModal.module.css create mode 100644 components/Profile/ParentProfile/AddStudentModal.tsx create mode 100644 components/Profile/ParentProfile/ConnectStudentProfile.module.css create mode 100644 components/Profile/ParentProfile/ConnectStudentProfile.tsx create mode 100644 components/Profile/ParentProfile/ConnectedStudentDisplay.module.css create mode 100644 components/Profile/ParentProfile/ConnectedStudentDisplay.tsx create mode 100644 components/Profile/ParentProfile/ParentEditProfilePanel.module.css create mode 100644 components/Profile/ParentProfile/ParentEditProfilePanel.tsx create mode 100644 components/Profile/ParentProfile/ParentProfileView.module.css create mode 100644 components/Profile/ParentProfile/ParentProfileView.tsx create mode 100644 components/Profile/ParentProfile/ProfileInput.module.css create mode 100644 components/Profile/ParentProfile/ProfileInput.tsx create mode 100644 components/Profile/ParentProfile/ProfilePicture.module.css create mode 100644 components/Profile/ParentProfile/ProfilePicture.tsx create mode 100644 lib/database/parents.ts create mode 100644 migrations/0012_create-parents-table.sql delete mode 100644 models/Attendance.ts delete mode 100644 models/AttendanceTypes.ts delete mode 100644 models/CalendarEvent.ts delete mode 100644 models/ClassEvent.ts delete mode 100644 models/CreateAttendance.ts delete mode 100644 models/CreateClass.ts delete mode 100644 models/CreateClassEvent.ts delete mode 100644 models/CreateItem.ts delete mode 100644 models/CreateModule.ts delete mode 100644 models/CreateUser.ts delete mode 100644 models/Image.ts delete mode 100644 models/Item.ts delete mode 100644 models/Module.ts delete mode 100644 models/Roles.ts delete mode 100644 models/SingleUserAttendance.ts delete mode 100644 models/Student.ts delete mode 100644 models/UpdateClass.ts delete mode 100644 models/UpdateImage.ts delete mode 100644 models/UpdateModule.ts delete mode 100644 models/UpdateUser.ts delete mode 100644 models/User.ts delete mode 100644 models/index.ts create mode 100644 pages/api/parents/[id]/student/index.ts diff --git a/.gitignore b/.gitignore index 4f17b95d..b9fcb129 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ yarn-error.log* /public/swagger.json /models/*.ts + diff --git a/__tests__/parents.test.ts b/__tests__/parents.test.ts new file mode 100644 index 00000000..165cc765 --- /dev/null +++ b/__tests__/parents.test.ts @@ -0,0 +1,90 @@ +import parentStudentHandler from "../pages/api/parents/[id]/student"; +import { client } from "../lib/db"; +import { makeHTTPRequest } from "./__testutils__/testutils.test"; +import { CreateParentStudentLink, ParentStudentLink } from "../models"; +import { StatusCodes } from "http-status-codes"; + +const STUDENT_NOT_FOUND_ERROR = "Student not found"; +beforeAll(async () => { + await client.query("DELETE from users"); + await client.query("DELETE from event_information"); + await client.query("DELETE from commitments"); + await client.query("DELETE from classes"); + await client.query("DELETE from images"); + await client.query("INSERT INTO images(id) VALUES('1')"); + await client.query( + "INSERT INTO users(id, first_name, last_name, email, role, address, phone_number, date_created, picture_id) VALUES('1', 'John', 'Doe', 'john@gmail.com', 'Student', '123 Main Street', '1234567890', '5/23/2022, 4:45:03 AM', '1')" + ); + await client.query( + "INSERT INTO users(id, first_name, last_name, email, role, address, phone_number, date_created, picture_id) VALUES('2', 'Jane', 'Doe', 'jane@gmail.com', 'Parent', '123 Main Street', '1234567890', '5/23/2022, 4:45:03 AM', '1')" + ); + await client.query( + "INSERT INTO users(id, first_name, last_name, email, role, approved, address, phone_number, date_created, picture_id) VALUES('3', 'Teacher', 'Doe', 'teacher@gmail.com', 'Teacher', false, '123 Main Street', '1234567890', '5/23/2022, 4:45:03 AM', '1')" + ); + await client.query( + "INSERT INTO users(id, first_name, last_name, email, role, approved, address, phone_number, date_created, picture_id) VALUES('4', 'Admin', 'Doe', 'admin@gmail.com', 'Admin', false, '123 Main Street', '1234567890', '5/23/2022, 4:45:03 AM', '1')" + ); +}); + +afterAll(async () => { + await client.query("DELETE from users"); + await client.end(); +}); + +describe("[POST] /api/parents/[id]/student", () => { + test("Create a valid parent-student link", async () => { + const body: CreateParentStudentLink = { + email: "john@gmail.com", + }; + const query = { + id: "2", + }; + const expected: ParentStudentLink = { + parentId: "2", + studentId: "1", + }; + await makeHTTPRequest( + parentStudentHandler, + "/api/parents/2/student", + query, + "POST", + body, + StatusCodes.OK, + expected + ); + }); + test("Create an invalid link between a parent and a non-student", async () => { + const body: CreateParentStudentLink = { + email: "teacher@gmail.com", + }; + const query = { + id: "2", + }; + await makeHTTPRequest( + parentStudentHandler, + "/api/parents/2/student", + query, + "POST", + body, + StatusCodes.NOT_FOUND, + STUDENT_NOT_FOUND_ERROR + ); + }); + test("Create an invalid link between a parent and a nonexistent user", async () => { + const body: CreateParentStudentLink = { + email: "notAnEmail@gmail.com", + }; + const query = { + id: "2", + }; + await makeHTTPRequest( + parentStudentHandler, + "/api/parents/2/student", + query, + "POST", + body, + StatusCodes.NOT_FOUND, + STUDENT_NOT_FOUND_ERROR + ); + }); +}); diff --git a/__tests__/users.test.ts b/__tests__/users.test.ts index 67f36c9d..51703968 100644 --- a/__tests__/users.test.ts +++ b/__tests__/users.test.ts @@ -51,7 +51,7 @@ describe("[GET] /api/users/?filter", () => { lastName: "Doe", email: "john@gmail.com", role: "Student", - approved: true, + approved: false, dateCreated: "5/23/2022, 4:45:03 AM", address: "123 Main Street", phoneNumber: "1234567890", @@ -63,7 +63,7 @@ describe("[GET] /api/users/?filter", () => { lastName: "Doe", email: "john@gmail.com", role: "Student", - approved: true, + approved: false, dateCreated: "5/23/2022, 4:45:03 AM", address: "123 Main Street", phoneNumber: "1234567890", @@ -407,7 +407,7 @@ describe("[POST] /api/users", () => { lastName: "Doe", email: "mynaME@gmail.com", role: "Student", - approved: true, + approved: false, pictureId: "", dateCreated: "", address: null, diff --git a/components/Login/LoginPositionInput.tsx b/components/Login/LoginPositionInput.tsx index dcc4c2ab..3304c3c1 100644 --- a/components/Login/LoginPositionInput.tsx +++ b/components/Login/LoginPositionInput.tsx @@ -50,7 +50,7 @@ const LoginPositionInput: React.FC = ({

onContentChange("Student")} @@ -73,6 +73,19 @@ const LoginPositionInput: React.FC = ({ +

+ onContentChange("Parent")} + className={styles.radioBox} + checked={currPosition == "Parent"} + /> + diff --git a/components/Navbar/Navbar.tsx b/components/Navbar/Navbar.tsx index 93cd0720..24c1a530 100644 --- a/components/Navbar/Navbar.tsx +++ b/components/Navbar/Navbar.tsx @@ -5,13 +5,7 @@ import { AuthContext } from "../../context/AuthContext"; export const Navbar: React.FC = ({ children }) => { const router = useRouter(); - // let authUser = null; - // try { const { user } = useContext(AuthContext); - // authUser = user; - // } catch { - // return null; - // } if (user == null) return null; return ( @@ -41,7 +35,7 @@ export const Navbar: React.FC = ({ children }) => { - {user.role != "Student" ? ( + {user.role != "Student" && user.role != "Parent" ? (
  • void; + linkParentAndStudent: (studentEmail: string) => Promise; + errorMsg: string; +}; +// modal to add students +export const AddStudentModal: React.FC = ({ + setShowModalState, + linkParentAndStudent, + errorMsg, +}) => { + const [studentEmail, setStudentEmail] = useState(""); + + return ( +
    +
    + {/* eslint-disable-next-line react/no-unescaped-entities */} +
    Enter Your Student's Email:
    + + setStudentEmail(e.target.value)} + className={styles.input} + id="standard-basic" + variant="standard" + /> +
    +
    + + +
    +
    {errorMsg != "" ? "Error: " + errorMsg : ""}
    +
    +
    + ); +}; diff --git a/components/Profile/ParentProfile/ConnectStudentProfile.module.css b/components/Profile/ParentProfile/ConnectStudentProfile.module.css new file mode 100644 index 00000000..78746477 --- /dev/null +++ b/components/Profile/ParentProfile/ConnectStudentProfile.module.css @@ -0,0 +1,26 @@ +.createStudentContainer { + margin-bottom: 59px; +} + +.connectStudentContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin: 24px 32px 32px; +} + +.connectStudentText { + font-family: "Inter"; + font-weight: bold; + font-size: 24px; + text-align: center; + vertical-align: Top; + color: #959595; +} + +.buttonContainer { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; +} diff --git a/components/Profile/ParentProfile/ConnectStudentProfile.tsx b/components/Profile/ParentProfile/ConnectStudentProfile.tsx new file mode 100644 index 00000000..937688e2 --- /dev/null +++ b/components/Profile/ParentProfile/ConnectStudentProfile.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import styles from "./ConnectStudentProfile.module.css"; +import IconButton from "@mui/material/IconButton"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import AddIcon from "@mui/icons-material/Add"; + +type ConnectStudentProfileProps = { + setShowModalState: (newState: boolean) => void; + incrementStudent: () => void; + decrementStudent: () => void; +}; + +// card to connect a student to the current parent. Main function is to open the modal +export const ConnectStudentProfile: React.FC = ({ + setShowModalState, + incrementStudent, + decrementStudent, +}) => { + return ( +
    +
    +
    Connect Your Student
    +
    + decrementStudent()}> + + + { + incrementStudent(); + }} + > + + +
    +
    +
    +
    + setShowModalState(true)} + > + + +
    +
    +
    + ); +}; diff --git a/components/Profile/ParentProfile/ConnectedStudentDisplay.module.css b/components/Profile/ParentProfile/ConnectedStudentDisplay.module.css new file mode 100644 index 00000000..c401c8e7 --- /dev/null +++ b/components/Profile/ParentProfile/ConnectedStudentDisplay.module.css @@ -0,0 +1,47 @@ +.currentStudentContainer { + margin: 24px 32px 32px; +} + +.nameAndArrowContainer { + display: flex; + justify-content: space-between; +} + +.studentsText { + font-family: "Inter"; + font-weight: bold; + font-size: 24px; + color: black; + padding-bottom: 16px; +} + +.studentInfoContainer { + margin-left: 8px; +} + +.studentName { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-size: 18px; + line-height: 22px; + padding-bottom: 16px; +} + +.phoneText { + font-family: "Inter"; + font-style: normal; + font-weight: 600; + font-size: 15px; + line-height: 18px; + padding-bottom: 9px; +} + +.phoneVal { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + font-size: 15px; + line-height: 18px; + padding-bottom: 16px; +} diff --git a/components/Profile/ParentProfile/ConnectedStudentDisplay.tsx b/components/Profile/ParentProfile/ConnectedStudentDisplay.tsx new file mode 100644 index 00000000..6e1d1db5 --- /dev/null +++ b/components/Profile/ParentProfile/ConnectedStudentDisplay.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import styles from "./ConnectedStudentDisplay.module.css"; +import IconButton from "@mui/material/IconButton"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import { User } from "../../../models"; + +type ConnectedStudentDisplayProps = { + student: User; + incrementStudent: () => void; + decrementStudent: () => void; +}; +// displays the linked students to the parent. This is for one student, we use +// an array of these and switch between them in the profile view +export const ConnectedStudentDisplay: React.FC = ({ + student, + incrementStudent, + decrementStudent, +}) => { + return ( +
    +
    +
    Students
    +
    + decrementStudent()}> + + + incrementStudent()}> + + +
    +
    + +
    +
    {student.firstName + " " + student.lastName}
    +
    Phone
    +
    {student.phoneNumber ? student.phoneNumber : "None"}
    +
    Email
    +
    {student.email}
    +
    +
    + ); +}; diff --git a/components/Profile/ParentProfile/ParentEditProfilePanel.module.css b/components/Profile/ParentProfile/ParentEditProfilePanel.module.css new file mode 100644 index 00000000..4549606a --- /dev/null +++ b/components/Profile/ParentProfile/ParentEditProfilePanel.module.css @@ -0,0 +1,74 @@ +.container { + display: block; + height: 100%; + overflow-y: auto; +} + +.profilePictureContainer { + display: flex; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; +} + +.circlePadding { + height: 1rem; + flex-basis: 100%; +} +.padding { + flex-basis: 100%; + height: 0.5rem; +} + +.divider { + border: 1px solid #85888c; + width: 80%; + height: 0; + margin-bottom: 1rem; +} + +.name { + font-size: 2rem; + text-align: center; + flex-basis: 100%; + margin: 0.5rem 0 0.5rem 0; +} + +.inputContainerDefault, +.inputContainerWithPassword { + display: flex; + height: auto; + flex-direction: column; + flex-wrap: wrap; + align-items: center; + width: 100%; +} + +.errorMessage { + color: red; + height: 15px; + font-family: "Inter"; + font-style: normal; + font-weight: 500; + font-size: 15px; + line-height: 15px; + width: 80%; + flex-basis: 100%; + text-align: center; +} + +.buttonContainer { + width: 80%; + display: flex; + flex-direction: row; + justify-content: space-evenly; + margin-top: 1rem; +} + +.backButton, +.editButton, +.signOutButton { + text-transform: none; +} diff --git a/components/Profile/ParentProfile/ParentEditProfilePanel.tsx b/components/Profile/ParentProfile/ParentEditProfilePanel.tsx new file mode 100644 index 00000000..b7a3d20a --- /dev/null +++ b/components/Profile/ParentProfile/ParentEditProfilePanel.tsx @@ -0,0 +1,308 @@ +import React, { useEffect, useState } from "react"; +import styles from "./ParentEditProfilePanel.module.css"; +import { CustomLoader } from "../../util/CustomLoader"; +import { ProfilePicture } from "./ProfilePicture"; +import { Button } from "@mui/material"; +import { useRouter } from "next/router"; +import { AuthState } from "../../../context/AuthContext"; +import { LeagueAPI } from "../../../context/LeagueAPI"; +import { UpdateImage, User } from "../../../models"; +import { fromByteArray } from "base64-js"; +import imageCompression from "browser-image-compression"; +import { ProfileInput } from "./ProfileInput"; + +type ParentEditProfilePanelProps = { + loggedInUser: User; + authState: AuthState; + leagueAPI: LeagueAPI; + customUser: boolean; +}; + +// Component for left hand side of the parent profile view. +// Displays parent profile info and allows for editing, including name, phone, email, birthday, address, and password +const ParentEditProfilePanel: React.FC = ({ + loggedInUser, + authState, + leagueAPI, + customUser, +}) => { + const { error, updateUser, clearError, logout } = authState; + + const api = leagueAPI; + + const router = useRouter(); + const [editProfileClicked, setEditProfileClicked] = useState(false); + const [phoneNumber, setPhoneNumber] = useState( + loggedInUser.phoneNumber + ); + const [email, setEmail] = useState(loggedInUser.email); + const [address, setAddress] = useState(loggedInUser.address || ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [image, setImage] = useState(null); + const [imageLoading, setImageLoading] = useState(false); + const [imageChanged, setImageChanged] = useState(false); + // the user being displayed + const [user, _setUser] = useState(loggedInUser); + // if a custom user is being rendered, make it not editable + const [editable, _setEditable] = useState(customUser); + + useEffect(() => { + (async () => { + await resetImage(); + })(); + }, []); + + const resetImage = async (): Promise => { + setImageLoading(true); + const image = await api.getImage(loggedInUser.pictureId); + if (image.img == null) { + setImage(null); + } else { + const buf = Buffer.from(image.img, "base64"); + const fileBits = new Uint8Array(buf); + const f = new File([fileBits], ""); + setImage(f); + } + setImageLoading(false); + }; + + const handleEditProfileClicked = async (): Promise => { + if (!editProfileClicked) { + setEditProfileClicked(true); + } else { + clearError(); + setErrorMessage(""); + + // compress image and make sure that the file can be compressed + if (imageChanged && image != null) { + const imageFile = image; + const options = { + maxSizeMB: 3, + useWebWorker: true, + }; + try { + const compressedFile = await imageCompression(imageFile, options); + setImage(compressedFile); + } catch { + setErrorMessage("Something went wrong. Try a smaller file..."); + return; + } + } + + const userSuccess = await updateUser( + loggedInUser.id, + loggedInUser.email, + currentPassword, + email, + phoneNumber, + newPassword, + address + ); + let imageSuccess = true; + if (imageChanged && image != null && userSuccess) { + const imageType = image.type; + const imageData = await image.arrayBuffer(); + const imageDataBits = new Uint8Array(imageData); + const b64img = fromByteArray(imageDataBits); + const updatedImage: UpdateImage = { + mimeType: imageType, + img: b64img, + }; + const updatedImageFromDB = api.updateImage(loggedInUser.pictureId, updatedImage); + if (!updatedImageFromDB) imageSuccess = false; + } + if (userSuccess && imageSuccess) { + setEditProfileClicked(false); + setImageChanged(false); + } + } + }; + + const onBackClick = async (): Promise => { + setPhoneNumber(loggedInUser.phoneNumber); + setEmail(loggedInUser.email); + setAddress(loggedInUser.address || ""); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + clearError(); + setErrorMessage(""); + setEditProfileClicked(false); + await resetImage(); + }; + + const onSignoutClick = (): void => { + logout(); + router.push("/login"); + }; + + const handleEmailChange = (newEmail: string): void => { + setEmail(newEmail); + }; + + const handlePhoneNumberChange = (newNumber: string): void => { + setPhoneNumber(newNumber); + }; + + const handleAddressChange = (newAddress: string): void => { + setAddress(newAddress); + }; + + const handleCurrentPasswordChange = (newVal: string): void => { + setCurrentPassword(newVal); + }; + + const handleNewPassword = (newPassword: string): void => { + setNewPassword(newPassword); + }; + + const handleConfirmPassword = (confirmPassword: string): void => { + setConfirmPassword(confirmPassword); + }; + + const handleImageChange = (newImage: File): void => { + setImageChanged(true); + setImage(newImage); + }; + + const regEx = RegExp(/\S+@\S+\.\S+/); + const validEmail = regEx.test(email); + const phoneRegEx = RegExp(/\(?([0-9]{3})\)?([ .-]?)([0-9]{3})\2([0-9]{4})/); + const validPhoneNumber = + phoneNumber === null || + phoneNumber === "" || + (phoneNumber != undefined && + !/[a-z]/i.test(phoneNumber) && + phoneRegEx.test(phoneNumber) && + !(phoneNumber.length > 11)); + const validPassword = newPassword === "" || newPassword.length > 6; + const validConfirmPassword = newPassword === confirmPassword; + const validInput = validPhoneNumber && validPassword && validEmail && validConfirmPassword; + + useEffect(() => { + clearError(); + }, []); + useEffect(() => { + setErrorMessage( + error != null + ? error.message + : !validEmail + ? "Enter a valid email" + : !validPhoneNumber + ? "Enter a valid phone number" + : !validPassword + ? "Passwords must be at least 6 characters" + : !validConfirmPassword + ? "Passwords do not match" + : "" + ); + }, [validEmail, validPassword, validPhoneNumber, validConfirmPassword, error]); + const buttonText = editProfileClicked ? "Save" : "Edit Profile"; + const disabled = !editProfileClicked; + + return ( +
    +
    +
    + {imageLoading ? ( + + ) : ( + + )} +
    {user.firstName + " " + user.lastName}
    +
    +
    +
    + + + + + {!disabled && [ + , + , + , + ]} + +
    {errorMessage}
    + + {editable ? ( +
    + {!disabled && ( + + )} + + +
    + ) : null} +
    +
    + ); +}; + +export { ParentEditProfilePanel }; diff --git a/components/Profile/ParentProfile/ParentProfileView.module.css b/components/Profile/ParentProfile/ParentProfileView.module.css new file mode 100644 index 00000000..0229b8e0 --- /dev/null +++ b/components/Profile/ParentProfile/ParentProfileView.module.css @@ -0,0 +1,49 @@ +.rectangleContainer { + display: flex; + justify-content: center; +} + +.rectangle { + background: #ffffff; + width: 90%; + height: 734px; + box-sizing: border-box; + border-radius: 8px; +} + +.contentContainer { + display: flex; + justify-content: center; + height: 90%; +} + +.leftPanel, +.middlePanel, +.rightPanel { + border-radius: 8px; + box-shadow: 1px 2px 8px rgba(0, 0, 0, 0.25); + border: 0.5px solid #bfc1c4; +} + +.leftPanel { + width: 25%; +} + +.middlePanel { + width: 40%; + height: 245px; + margin: 0 1rem 0 1rem; +} + +.rightPanel { + width: 35%; +} + +.addStudentButton { + width: 90px; + height: 90px; +} + +.MuiSvgIcon-root { + height: 89px !important; +} diff --git a/components/Profile/ParentProfile/ParentProfileView.tsx b/components/Profile/ParentProfile/ParentProfileView.tsx new file mode 100644 index 00000000..6737c702 --- /dev/null +++ b/components/Profile/ParentProfile/ParentProfileView.tsx @@ -0,0 +1,144 @@ +import React, { useContext, useEffect, useState } from "react"; +import { AuthContext } from "../../../context/AuthContext"; +import styles from "./ParentProfileView.module.css"; +import { CustomError } from "../../util/CustomError"; +import { APIContext } from "../../../context/APIContext"; +import { User } from "../../../models/"; +import { ParentEditProfilePanel } from "./ParentEditProfilePanel"; +import axios from "axios"; +import { ConnectStudentProfile } from "./ConnectStudentProfile"; +import { ConnectedStudentDisplay } from "./ConnectedStudentDisplay"; +import { AddStudentModal } from "./AddStudentModal"; + +// component that renders the admin/teacher profile page +type ParentProfileViewProps = { + otherUser?: User; // if we need to display a user that is not the logged in one, pass the details here +}; +const ParentProfileView: React.FC = ({ otherUser }) => { + const authState = useContext(AuthContext); + const { user: loggedInUser } = authState; + + const [showConnectStudentModal, setShowConnectStudentModal] = useState(false); + const [createParentStudentLinkError, setCreateParentStudentLinkError] = useState(""); + const [allStudents, setAllStudents] = useState([]); + const [currentStudentIndex, setCurrentStudentIndex] = useState(0); + const [showAddNewStudentBox, setShowAddNewStudentBox] = useState(true); + const [_, setCurrentStudent] = useState(null); + + const api = useContext(APIContext); + + // user will never be null, because if it is, client is redirected to login page + if (loggedInUser == null) return ; + + useEffect(() => { + (async () => { + await getStudentsLinkedToParent(); + })(); + }, []); + + const getStudentsLinkedToParent = async (): Promise => { + const studentsLinkedToParent = await api.getStudentsLinkedToParent(loggedInUser.id); + setAllStudents(studentsLinkedToParent); + // if there is already one connected student, set the first one as the main one + if (studentsLinkedToParent.length > 0) { + setCurrentStudent(studentsLinkedToParent[0]); + setShowAddNewStudentBox(false); + } + }; + + // function to handle going right in the cards of linked students + const incrementStudent = (): void => { + const newIndex = currentStudentIndex + 1; + if (newIndex < allStudents.length) { + setCurrentStudentIndex(newIndex); + setCurrentStudent(allStudents[newIndex]); + } + + // if incrementing past end of student list, render the add student box + if (newIndex == allStudents.length) { + setShowAddNewStudentBox(true); + setCurrentStudentIndex(newIndex); + } + }; + + // function to handle going left on the cards of linked students + const decrementStudent = (): void => { + const newIndex = currentStudentIndex - 1; + + if (newIndex > -1) { + // decrementing should stop displaying the add student box + setShowAddNewStudentBox(false); + setCurrentStudentIndex(newIndex); + setCurrentStudent(allStudents[newIndex]); + setShowAddNewStudentBox(false); + } + }; + + // array of rendered student cards (we flip between these when hitting left/right arrows) + const renderStudentCards = allStudents.map((student) => ( + + )); + + const setShowConnectStudentState = (newState: boolean): void => { + setShowConnectStudentModal(newState); + }; + + // function used to link parent and student + const createParentStudentLink = async (studentEmail: string): Promise => { + try { + await api.createParentStudentLink(loggedInUser.id, { email: studentEmail }); + await getStudentsLinkedToParent(); + setShowConnectStudentState(false); + } catch (err) { + if (axios.isAxiosError(err) && err.response) + setCreateParentStudentLinkError(err.response.data); + else if (err instanceof Error) setCreateParentStudentLinkError(err.message); + else setCreateParentStudentLinkError("Error"); + } + }; + + return ( +
    +
    +
    +
    +
    + +
    +
    + {showAddNewStudentBox ? ( + + ) : ( + renderStudentCards[currentStudentIndex] + )} +
    +
    +
    +
    +
    + {showConnectStudentModal && ( + + )} +
    + ); +}; + +export { ParentProfileView }; diff --git a/components/Profile/ParentProfile/ProfileInput.module.css b/components/Profile/ParentProfile/ProfileInput.module.css new file mode 100644 index 00000000..e928c5dc --- /dev/null +++ b/components/Profile/ParentProfile/ProfileInput.module.css @@ -0,0 +1,9 @@ +.font { + composes: titleSmall from "../../../styles/globals.css"; +} + +.textBoxPadding { + width: 80%; + display: block; + margin: 0.5rem 0 0.5rem 0; +} diff --git a/components/Profile/ParentProfile/ProfileInput.tsx b/components/Profile/ParentProfile/ProfileInput.tsx new file mode 100644 index 00000000..9b84c806 --- /dev/null +++ b/components/Profile/ParentProfile/ProfileInput.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import TextField from "@mui/material/TextField"; +import styles from "./ProfileInput.module.css"; + +type ProfileInputProps = { + label: string; + defaultValue: string | null; + disabled: boolean; + password?: boolean; + onContentChange?: (newValue: string) => void; +}; + +const cssTextField = { + width: "100%", + height: "3.5rem", +}; + +// component that handles the textboxes for the admin profile view +const ProfileInput: React.FC = ({ + label, + defaultValue, + disabled, + password, + onContentChange, +}) => { + // on content change not always defined because some fields cannot be changed + const handleContentChange = (newContent: string): void => { + if (!onContentChange) return; + else onContentChange(newContent); + return; + }; + + return ( +
    + handleContentChange(e.target.value)} + /> +
    + ); +}; + +export { ProfileInput }; diff --git a/components/Profile/ParentProfile/ProfilePicture.module.css b/components/Profile/ParentProfile/ProfilePicture.module.css new file mode 100644 index 00000000..f461e778 --- /dev/null +++ b/components/Profile/ParentProfile/ProfilePicture.module.css @@ -0,0 +1,39 @@ +.circleContainer { + display: flex; + justify-content: center; +} + +.dropZone { + display: flex; + justify-content: center; +} + +.dropZoneContentDisabled { + height: 180px; + width: 180px; + background-color: #bbb; + border-radius: 50%; + cursor: auto; +} + +.dropZoneContent { + composes: dropZoneContentDisabled; + cursor: pointer; +} + +.dropZoneImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.dropZoneText { + color: red; +} + +.circle { + height: 180px; + width: 180px; + background-color: #bbb; + border-radius: 50%; +} diff --git a/components/Profile/ParentProfile/ProfilePicture.tsx b/components/Profile/ParentProfile/ProfilePicture.tsx new file mode 100644 index 00000000..338fba20 --- /dev/null +++ b/components/Profile/ParentProfile/ProfilePicture.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Avatar, SxProps } from "@mui/material"; +import styles from "./ProfilePicture.module.css"; + +type ProfilePictureProps = { + profileEditable: boolean; + onImageChange: (img: File) => void; + firstName: string; + lastName: string; + image: File | null; + onError: (errorMsg: string) => void; +}; + +// handles uploading and displaying a profile picture +export const ProfilePicture: React.FC = ({ + profileEditable, + onImageChange, + firstName, + lastName, + image, + onError, +}) => { + // console.log(lastName) + const fileInput = useRef(null); + const [previewUrl, setPreviewUrl] = useState(""); + const acceptableMimeType = ["image/png", "image/jpeg"]; + + useEffect(() => { + if (image != null) { + setPreviewUrl(URL.createObjectURL(image)); + } + }, [image]); + + const handleOnDragOver = (e: React.DragEvent): void => { + e.preventDefault(); + }; + + const handleOnDrop = (e: React.DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + if (!profileEditable) return; + const imageFile = e.dataTransfer.files[0]; + if (imageFile != null && acceptableMimeType.indexOf(imageFile.type) == -1) { + onError("File must be of type jpeg or png"); + return; + } else { + handleFile(imageFile); + } + }; + + const handleFile = (file: File): void => { + if (file != null) { + onImageChange(file); + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } + }; + + // generates color of avatar bubble from string + const stringToColor = (string: string): string => { + let hash = 0; + let i; + + for (i = 0; i < string.length; i += 1) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = "#"; + + for (i = 0; i < 3; i += 1) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.slice(-2); + } + /* eslint-enable no-bitwise */ + + return color; + }; + + type StringAvatarProps = { + sx: SxProps; + children: string; + }; + + const stringAvatar = (name: string): StringAvatarProps => { + return { + sx: { + bgcolor: stringToColor(name), + width: 180, + height: 180, + fontSize: 80, + }, + children: `${name.split(" ")[0][0]}${name.split(" ")[1][0]}`, + }; + }; + + return ( +
    { + if (fileInput.current != null && profileEditable) fileInput.current.click(); + }} + > +
    + {image != null ? ( + + ) : ( + + )} + { + if (e.target != null && e.target.files != null) { + handleFile(e.target.files[0]); + } + }} + /> +
    +
    + ); +}; diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index 1de62972..35407022 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -5,7 +5,7 @@ import { FirebaseError } from "@firebase/util"; import { Roles, UpdateUser, User } from "../models"; import { APIContext } from "./APIContext"; -type AuthState = { +export type AuthState = { user: User | null; error: Error | null; initializing: boolean; @@ -27,7 +27,8 @@ type AuthState = { currPassword: string, newEmail: string, newNumber?: string | null, - newPassword?: string + newPassword?: string, + newAddress?: string | null ) => Promise; }; @@ -283,7 +284,8 @@ export const AuthProvider: React.FC = ({ children }) => { currPassword: string, newEmail: string, newNumber?: string | null, - newPassword?: string + newPassword?: string, + newAddress?: string | null ): Promise => { try { if (currPassword === "") { @@ -308,6 +310,7 @@ export const AuthProvider: React.FC = ({ children }) => { const updateUser: UpdateUser = { phoneNumber: newNumber, email: newEmail, + address: newAddress, }; const newUser = await api.updateUser(updateUser, id); setUser(newUser); diff --git a/context/LeagueAPI.ts b/context/LeagueAPI.ts index 5786fc2a..f01d3add 100644 --- a/context/LeagueAPI.ts +++ b/context/LeagueAPI.ts @@ -1,22 +1,30 @@ import axios, { AxiosInstance } from "axios"; import { + Announcement, + Attendance, + Availability, Class, + ClassEvent, + CreateAnnouncement, + CreateAttendance, CreateClass, + CreateClassEvent, + CreateOneOffEvent, + CreateParentStudentLink, + CreateUser, + Image, Item, + MissingAttendance, Module, - Student, - CreateOneOffEvent, OneOffEvent, + ParentStudentLink, SessionInformation, - MissingAttendance, + Staff, + Student, + UpdateImage, + UpdateUser, + User, } from "../models"; -import { ClassEvent, CreateClassEvent } from "../models"; -import { Staff } from "../models"; -import { CreateUser, UpdateUser, User } from "../models"; -import { Image, UpdateImage } from "../models"; -import { Availability } from "../models"; -import { Attendance, CreateAttendance } from "../models"; -import { Announcement, CreateAnnouncement } from "../models"; // LeagueAPI class to connect front and backend class LeagueAPI { @@ -257,6 +265,19 @@ class LeagueAPI { await this.client.delete(`api/class/${classId}/student/${studentId}`); } + async createParentStudentLink( + parentId: string, + createParentStudentLink: CreateParentStudentLink + ): Promise { + const res = await this.client.post(`api/parents/${parentId}/student`, createParentStudentLink); + return res.data; + } + + async getStudentsLinkedToParent(parentId: string): Promise { + const res = await this.client.get(`api/parents/${parentId}/student`); + return res.data; + } + async getMissingAttednance(classId: string): Promise { const res = await this.client.get(`api/class/${classId}/missing_attendance`); return res.data; diff --git a/lib/database/parents.ts b/lib/database/parents.ts new file mode 100644 index 00000000..5946fbe8 --- /dev/null +++ b/lib/database/parents.ts @@ -0,0 +1,36 @@ +import { client } from "../db"; +import { array } from "io-ts"; +import { User } from "../../models"; +import { decode } from "io-ts-promise"; + +const UserArraySchema = array(User); + +// Creates a parent-student link in database +const linkParentAndStudent = async (parent_id: string, student_id: string): Promise => { + // Return type is Any[] because an empty array should be returned + const query = { + text: "INSERT INTO parents(parent_id, student_id) VALUES($1, $2)", + values: [parent_id, student_id], + }; + + await client.query(query); + + const approveStudentQuery = { + text: "UPDATE users SET approved = true WHERE id = $1", + values: [student_id], + }; + + await client.query(approveStudentQuery); +}; + +const getAllStudentsWithAParent = async (parentId: string): Promise => { + const query = { + text: "SELECT * FROM users INNER JOIN parents on id = student_id WHERE parent_id = $1", + values: [parentId], + }; + + const res = await client.query(query); + return await decode(UserArraySchema, res.rows); +}; + +export { linkParentAndStudent, getAllStudentsWithAParent }; diff --git a/lib/database/users.ts b/lib/database/users.ts index b3d209fc..fb2a955c 100644 --- a/lib/database/users.ts +++ b/lib/database/users.ts @@ -33,7 +33,7 @@ const createUser = async ( ): Promise => { const approved = process.env.ALWAYS_APPROVE ? process.env.ALWAYS_APPROVE == "true" - : !(role == "Admin" || role == "Teacher"); + : !(role == "Admin" || role == "Teacher" || role == "Student"); const currentDate = new Date(); const dateCreated = currentDate.toLocaleString("en-US", { timeZone: "America/Los_Angeles", @@ -96,6 +96,21 @@ const getUser = async (id: string): Promise => { return await decode(User, res.rows[0]); }; +const getUserByEmail = async (email: string): Promise => { + const query = { + text: "SELECT id, first_name, last_name, email, role, phone_number, address, picture_id, approved, date_created FROM users WHERE email = $1", + values: [email], + }; + + const res = await client.query(query); + + if (res.rows.length == 0) { + return null; + } + + return await decode(User, res.rows[0]); +}; + const deleteUser = async (id: string): Promise => { const query = { text: "delete from users where id = $1", @@ -116,4 +131,4 @@ const getAllUsers = async (): Promise => { return await decode(UserArraySchema, res.rows); }; -export { createUser, getUser, updateUser, getAllUsers, deleteUser }; +export { createUser, getUser, getUserByEmail, updateUser, getAllUsers, deleteUser }; diff --git a/migrations/0012_create-parents-table.sql b/migrations/0012_create-parents-table.sql new file mode 100644 index 00000000..0fdef282 --- /dev/null +++ b/migrations/0012_create-parents-table.sql @@ -0,0 +1,11 @@ +/** + * This script sets up the parents table with its properties of + * parent id and student id. + **/ + +CREATE TABLE parents ( + parent_id text, + student_id text, + FOREIGN KEY (parent_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (student_id) REFERENCES users (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/models/Attendance.ts b/models/Attendance.ts deleted file mode 100644 index 58f59c9a..00000000 --- a/models/Attendance.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as t from "io-ts"; -import { AttendanceTypes } from "./AttendanceTypes"; - -export const Attendance = t.type({ - sessionId: t.string, - userId: t.string, - attendance: AttendanceTypes, - firstName: t.string, - lastName: t.string -}) - -export interface Attendance { - sessionId: string, - userId: string, - attendance: AttendanceTypes, - firstName: string, - lastName: string -} \ No newline at end of file diff --git a/models/AttendanceTypes.ts b/models/AttendanceTypes.ts deleted file mode 100644 index ee1b9cd7..00000000 --- a/models/AttendanceTypes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as t from "io-ts"; - - -export const AttendanceTypes = t.union([ - t.union([ - t.literal('Unexcused'), - t.literal('Excused'), - t.literal('Present') - ]), - t.null -]) - -export type AttendanceTypes = - ( - | - ( - | 'Unexcused' - | 'Excused' - | 'Present' - ) - | null - ) \ No newline at end of file diff --git a/models/CalendarEvent.ts b/models/CalendarEvent.ts deleted file mode 100644 index 7d79c40e..00000000 --- a/models/CalendarEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as t from "io-ts"; - - -export const CalendarEvent = t.type({ - id: t.string, - title: t.string, - backgroundColor: t.string, - start: t.string, - end: t.string -}) - -export interface CalendarEvent { - id: string, - title: string, - backgroundColor: string, - start: string, - end: string -} \ No newline at end of file diff --git a/models/ClassEvent.ts b/models/ClassEvent.ts deleted file mode 100644 index 48c27063..00000000 --- a/models/ClassEvent.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as t from "io-ts"; - - -export const ClassEvent = t.type({ - eventInformationId: t.string, - startTime: t.string, - endTime: t.string, - timeZone: t.string, - rrule: t.string, - language: t.string, - neverEnding: t.boolean, - backgroundColor: t.string -}) - -export interface ClassEvent { - eventInformationId: string, - startTime: string, - endTime: string, - timeZone: string, - rrule: string, - language: string, - neverEnding: boolean, - backgroundColor: string -} \ No newline at end of file diff --git a/models/CreateAttendance.ts b/models/CreateAttendance.ts deleted file mode 100644 index 6559a4ff..00000000 --- a/models/CreateAttendance.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as t from "io-ts"; -import { AttendanceTypes } from "./AttendanceTypes"; - -export const CreateAttendance = t.type({ - userId: t.string, - attendance: AttendanceTypes -}) - -export interface CreateAttendance { - userId: string, - attendance: AttendanceTypes -} \ No newline at end of file diff --git a/models/CreateClass.ts b/models/CreateClass.ts deleted file mode 100644 index 49d76909..00000000 --- a/models/CreateClass.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as t from "io-ts"; - - -export const CreateClass = t.type({ - minLevel: t.number, - maxLevel: t.number, - rrstring: t.string, - startTime: t.string, - endTime: t.string, - language: t.string, - eventInformationId: t.string -}) - -export interface CreateClass { - minLevel: number, - maxLevel: number, - rrstring: string, - startTime: string, - endTime: string, - language: string, - eventInformationId: string -} \ No newline at end of file diff --git a/models/CreateClassEvent.ts b/models/CreateClassEvent.ts deleted file mode 100644 index 8468299c..00000000 --- a/models/CreateClassEvent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as t from "io-ts"; - - -export const CreateClassEvent = t.type({ - startTime: t.string, - endTime: t.string, - timeZone: t.string, - rrule: t.string, - language: t.string, - neverEnding: t.boolean, - backgroundColor: t.string, - name: t.string, - teachers: t.array(t.string), - studentIds: t.array(t.string), - checkAvailabilities: t.boolean -}) - -export interface CreateClassEvent { - startTime: string, - endTime: string, - timeZone: string, - rrule: string, - language: string, - neverEnding: boolean, - backgroundColor: string, - name: string, - teachers: Array, - studentIds: Array, - checkAvailabilities: boolean -} \ No newline at end of file diff --git a/models/CreateItem.ts b/models/CreateItem.ts deleted file mode 100644 index 5a48ecfc..00000000 --- a/models/CreateItem.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as t from "io-ts"; - - -export const CreateItem = t.type({ - title: t.string, - link: t.string -}) - -export interface CreateItem { - title: string, - link: string -} \ No newline at end of file diff --git a/models/CreateModule.ts b/models/CreateModule.ts deleted file mode 100644 index 9fcdc558..00000000 --- a/models/CreateModule.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as t from "io-ts"; - - -export const CreateModule = t.type({ - name: t.string, - position: t.number, - classId: t.string -}) - -export interface CreateModule { - name: string, - position: number, - classId: string -} \ No newline at end of file diff --git a/models/CreateUser.ts b/models/CreateUser.ts deleted file mode 100644 index 7f7753e8..00000000 --- a/models/CreateUser.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as t from "io-ts"; -import { Roles } from "./Roles"; - -export const CreateUser = t.type({ - id: t.string, - firstName: t.string, - lastName: t.string, - email: t.string, - role: Roles -}) - -export interface CreateUser { - id: string, - firstName: string, - lastName: string, - email: string, - role: Roles -} \ No newline at end of file diff --git a/models/Image.ts b/models/Image.ts deleted file mode 100644 index 8654d0a3..00000000 --- a/models/Image.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as t from "io-ts"; - - -export const Image = t.type({ - img: t.union([ - t.string, - t.null - ]), - mimeType: t.union([ - t.string, - t.null - ]), - id: t.string -}) - -export interface Image { - img: - ( - | string - | null - ), - mimeType: - ( - | string - | null - ), - id: string -} \ No newline at end of file diff --git a/models/Item.ts b/models/Item.ts deleted file mode 100644 index 1da1da5c..00000000 --- a/models/Item.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as t from "io-ts"; - - -export const Item = t.type({ - title: t.string, - link: t.string, - moduleId: t.string, - itemId: t.string -}) - -export interface Item { - title: string, - link: string, - moduleId: string, - itemId: string -} \ No newline at end of file diff --git a/models/Module.ts b/models/Module.ts deleted file mode 100644 index ca5ad2c8..00000000 --- a/models/Module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as t from "io-ts"; - - -export const Module = t.type({ - moduleId: t.string, - name: t.string, - position: t.number, - classId: t.string -}) - -export interface Module { - moduleId: string, - name: string, - position: number, - classId: string -} \ No newline at end of file diff --git a/models/Roles.ts b/models/Roles.ts deleted file mode 100644 index a86e9a99..00000000 --- a/models/Roles.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as t from "io-ts"; - - -export const Roles = t.union([ - t.literal('Admin'), - t.literal('Volunteer'), - t.literal('Student'), - t.literal('Teacher'), - t.literal('Parent') -]) - -export type Roles = - ( - | 'Admin' - | 'Volunteer' - | 'Student' - | 'Teacher' - | 'Parent' - ) \ No newline at end of file diff --git a/models/SingleUserAttendance.ts b/models/SingleUserAttendance.ts deleted file mode 100644 index 6c2fa1ef..00000000 --- a/models/SingleUserAttendance.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as t from "io-ts"; -import { AttendanceTypes } from "./AttendanceTypes"; - -export const SingleUserAttendance = t.type({ - sessionId: t.string, - userId: t.string, - attendance: AttendanceTypes, - start: t.string -}) - -export interface SingleUserAttendance { - sessionId: string, - userId: string, - attendance: AttendanceTypes, - start: string -} \ No newline at end of file diff --git a/models/Student.ts b/models/Student.ts deleted file mode 100644 index d49f2995..00000000 --- a/models/Student.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as t from "io-ts"; -import { Roles } from "./Roles"; - -export const Student = t.type({ - id: t.string, - firstName: t.string, - lastName: t.string, - email: t.string, - role: Roles, - pictureId: t.string, - approved: t.boolean, - dateCreated: t.string, - phoneNumber: t.union([ - t.string, - t.null - ]), - address: t.union([ - t.string, - t.null - ]), - level: t.union([ - t.number, - t.null - ]), - classes: t.array(t.string) -}) - -export interface Student { - id: string, - firstName: string, - lastName: string, - email: string, - role: Roles, - pictureId: string, - approved: boolean, - dateCreated: string, - phoneNumber: - ( - | string - | null - ), - address: - ( - | string - | null - ), - level: - ( - | number - | null - ), - classes: Array -} \ No newline at end of file diff --git a/models/UpdateClass.ts b/models/UpdateClass.ts deleted file mode 100644 index a50d8626..00000000 --- a/models/UpdateClass.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as t from "io-ts"; - - -export const UpdateClass = t.partial({ - minLevel: t.number, - maxLevel: t.number, - rrstring: t.string, - startTime: t.string, - endTime: t.string, - language: t.string -}) - -export interface UpdateClass { - minLevel?: number, - maxLevel?: number, - rrstring?: string, - startTime?: string, - endTime?: string, - language?: string -} \ No newline at end of file diff --git a/models/UpdateImage.ts b/models/UpdateImage.ts deleted file mode 100644 index eaaaa5af..00000000 --- a/models/UpdateImage.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as t from "io-ts"; - - -export const UpdateImage = t.type({ - img: t.string, - mimeType: t.string -}) - -export interface UpdateImage { - img: string, - mimeType: string -} \ No newline at end of file diff --git a/models/UpdateModule.ts b/models/UpdateModule.ts deleted file mode 100644 index eb3c01c5..00000000 --- a/models/UpdateModule.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as t from "io-ts"; - - -export const UpdateModule = t.partial({ - name: t.string, - position: t.number -}) - -export interface UpdateModule { - name?: string, - position?: number -} \ No newline at end of file diff --git a/models/UpdateUser.ts b/models/UpdateUser.ts deleted file mode 100644 index 65a6ff71..00000000 --- a/models/UpdateUser.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as t from "io-ts"; -import { Roles } from "./Roles"; - -export const UpdateUser = t.partial({ - id: t.string, - firstName: t.string, - lastName: t.string, - email: t.string, - role: Roles, - pictureId: t.string, - approved: t.boolean, - phoneNumber: t.union([ - t.string, - t.null - ]), - address: t.union([ - t.string, - t.null - ]) -}) - -export interface UpdateUser { - id?: string, - firstName?: string, - lastName?: string, - email?: string, - role?: Roles, - pictureId?: string, - approved?: boolean, - phoneNumber?: - ( - | string - | null - ), - address?: - ( - | string - | null - ) -} \ No newline at end of file diff --git a/models/User.ts b/models/User.ts deleted file mode 100644 index ec62d5db..00000000 --- a/models/User.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as t from "io-ts"; -import { Roles } from "./Roles"; - -export const User = t.type({ - id: t.string, - firstName: t.string, - lastName: t.string, - email: t.string, - role: Roles, - pictureId: t.string, - approved: t.boolean, - dateCreated: t.string, - phoneNumber: t.union([ - t.string, - t.null - ]), - address: t.union([ - t.string, - t.null - ]) -}) - -export interface User { - id: string, - firstName: string, - lastName: string, - email: string, - role: Roles, - pictureId: string, - approved: boolean, - dateCreated: string, - phoneNumber: - ( - | string - | null - ), - address: - ( - | string - | null - ) -} \ No newline at end of file diff --git a/models/index.ts b/models/index.ts deleted file mode 100644 index 326ed34a..00000000 --- a/models/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./Announcement"; -export * from "./Attendance"; -export * from "./AttendanceTypes"; -export * from "./Availability"; -export * from "./CalendarEvent"; -export * from "./Class"; -export * from "./ClassEvent"; -export * from "./CreateAnnouncement"; -export * from "./CreateAttendance"; -export * from "./CreateClass"; -export * from "./CreateClassEvent"; -export * from "./CreateItem"; -export * from "./CreateModule"; -export * from "./CreateOneOffEvent"; -export * from "./CreateUser"; -export * from "./Image"; -export * from "./Item"; -export * from "./MissingAttendance"; -export * from "./Module"; -export * from "./OneOffEvent"; -export * from "./Roles"; -export * from "./SessionInformation"; -export * from "./SingleUserAttendance"; -export * from "./Staff"; -export * from "./Student"; -export * from "./UpdateClass"; -export * from "./UpdateImage"; -export * from "./UpdateModule"; -export * from "./UpdateUser"; -export * from "./User"; diff --git a/models/openapi/models.swagger.yaml b/models/openapi/models.swagger.yaml index 4c706fa9..e79678aa 100644 --- a/models/openapi/models.swagger.yaml +++ b/models/openapi/models.swagger.yaml @@ -665,6 +665,25 @@ components: - end - attendees - checkAvailabilities + CreateParentStudentLink: + title: CreateParentStudentLink + type: object + properties: + email: + type: string + required: + - email + ParentStudentLink: + title: ParentStudentLink + type: object + properties: + parentId: + type: string + studentId: + type: string + required: + - parentId + - studentId SessionInformation: title: SessionInformation type: object diff --git a/pages/api/parents/[id]/student/index.ts b/pages/api/parents/[id]/student/index.ts new file mode 100644 index 00000000..52e231ed --- /dev/null +++ b/pages/api/parents/[id]/student/index.ts @@ -0,0 +1,76 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; +import { StatusCodes } from "http-status-codes"; +import { withAuth } from "../../../../../middleware/withAuth"; +import { onError } from "../../../../../logger/logger"; +import { withLogging } from "../../../../../middleware/withLogging"; +import { + getAllStudentsWithAParent, + linkParentAndStudent, +} from "../../../../../lib/database/parents"; +import { CreateParentStudentLink, ParentStudentLink } from "../../../../../models"; +import { decode } from "io-ts-promise"; +import { getUserByEmail } from "../../../../../lib/database/users"; + +/** + * @swagger + * /api/parents/[id]/student: + * post: + * description: Adds a parent and student relationship to the database + * responses: + * 200: + * description: Parent and student successfully linked + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/ParentStudentLink' + * @param req + * @param res + */ +const parentStudentHandler: NextApiHandler = async (req: NextApiRequest, res: NextApiResponse) => { + let createParentStudentLink: CreateParentStudentLink; + switch (req.method) { + case "POST": + try { + createParentStudentLink = await decode(CreateParentStudentLink, req.body); + } catch (e) { + return res.status(StatusCodes.BAD_REQUEST).json("Fields are not correctly entered"); + } + try { + const parentId = req.query.id as string; + if (!parentId) { + return res.status(StatusCodes.BAD_REQUEST).json("Parent id not specified"); + } + const studentEmail = createParentStudentLink.email; + const student = await getUserByEmail(studentEmail); + if (!student || student.role !== "Student") { + return res.status(StatusCodes.NOT_FOUND).json("Student not found"); + } + await linkParentAndStudent(parentId, student.id); + const parentStudentLink: ParentStudentLink = { + parentId: parentId, + studentId: student.id, + }; + return res.status(StatusCodes.OK).json(parentStudentLink); + } catch (e) { + onError(e); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + case "GET": + try { + const parentId = req.query.id as string; + if (!parentId) { + return res.status(StatusCodes.BAD_REQUEST).json("Parent id not specified"); + } + const studentsLinked = await getAllStudentsWithAParent(parentId); + return res.status(StatusCodes.OK).json(studentsLinked); + } catch (e) { + onError(e); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + default: + return res.status(StatusCodes.METHOD_NOT_ALLOWED).json("Method not allowed"); + } +}; + +export default withLogging(withAuth(parentStudentHandler)); diff --git a/pages/profile/index.tsx b/pages/profile/index.tsx index 62e549c3..cd32848b 100644 --- a/pages/profile/index.tsx +++ b/pages/profile/index.tsx @@ -1,10 +1,22 @@ -import React from "react"; +import React, { useContext } from "react"; import { AdminTeacherProfileView } from "../../components/Profile/AdminTeacherProfile/AdminTeacherProfileView"; +import { ParentProfileView } from "../../components/Profile/ParentProfile/ParentProfileView"; import { NextApplicationPage } from "../_app"; +import { AuthContext } from "../../context/AuthContext"; +import { CustomError } from "../../components/util/CustomError"; //This is the page that is rendered when the 'Index' button from the Navbar is clicked const Index: NextApplicationPage = () => { - return ; + const { user } = useContext(AuthContext); + + if (user == null) return ; + + switch (user.role) { + case "Parent": + return ; + default: + return ; + } }; Index.requireAuth = true;