From d6194d84aad46421c91fe9a96b793484dfb3ce3c Mon Sep 17 00:00:00 2001 From: qingyuan Date: Fri, 30 Jan 2026 11:18:07 -0500 Subject: [PATCH 1/3] added supervised teaching page fuctionality --- src/app/applications/applicationSections.tsx | 7 + .../applications/supervisedTeaching/page.tsx | 438 ++++++++++++++++++ src/hooks/useGetItems.ts | 6 + src/types/navigation.ts | 2 +- 4 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/app/applications/supervisedTeaching/page.tsx diff --git a/src/app/applications/applicationSections.tsx b/src/app/applications/applicationSections.tsx index 487bfce..114601b 100644 --- a/src/app/applications/applicationSections.tsx +++ b/src/app/applications/applicationSections.tsx @@ -43,8 +43,15 @@ export default function ApplicationSections({ .map(({ label, to, icon: Icon }: NavbarItem) => ( ))} + + {navItems + .filter((item) => item.type === 'supervised-teaching') + .map(({ label, to, icon: Icon }: NavbarItem) => ( + + ))} +

Research

No available applications at this time.

diff --git a/src/app/applications/supervisedTeaching/page.tsx b/src/app/applications/supervisedTeaching/page.tsx new file mode 100644 index 0000000..ad278db --- /dev/null +++ b/src/app/applications/supervisedTeaching/page.tsx @@ -0,0 +1,438 @@ +'use client'; +import * as React from 'react'; +import Button from '@mui/material/Button'; +import CssBaseline from '@mui/material/CssBaseline'; +import TextField from '@mui/material/TextField'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import DepartmentSelect from '@/component/FormUtil/DepartmentSelect'; +import GPA_Select from '@/component/FormUtil/GPASelect'; +import Typography from '@mui/material/Typography'; +import DegreeSelect from '@/component/FormUtil/DegreeSelect'; +import SemesterStatusSelect from '@/component/FormUtil/SemesterStatusSelect'; +import AvailabilityCheckbox from '@/component/FormUtil/AvailabilityCheckbox'; +import AdditionalSemesterPrompt from '@/component/FormUtil/AddtlSemesterPrompt'; +import UpdateRole from '@/firebase/util/UpdateUserRole'; +import { useAuth } from '@/firebase/auth/auth_context'; +import { Toaster, toast } from 'react-hot-toast'; +import Snackbar from '@mui/material/Snackbar'; +import MuiAlert, { AlertProps } from '@mui/material/Alert'; +import 'firebase/firestore'; +import firebase from '@/firebase/firebase_config'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import FormControl from '@mui/material/FormControl'; +import Autocomplete from '@mui/material/Autocomplete'; +import HeaderCard from '@/components/HeaderCard/HeaderCard'; +import { + fetchClosestSemesters, + parseCoursesMinimal, +} from '@/hooks/useSemesterOptions'; +import { CourseOption } from '@/hooks/useSemesterOptions'; + +export default function SupervisedTeachingApplication() { + const router = useRouter(); + const { user } = useAuth(); + const userId = user.uid; + + const current = new Date(); + const current_date = `${ + current.getMonth() + 1 + }-${current.getDate()}-${current.getFullYear()}`; + + const [nationality, setNationality] = React.useState(null); + const [additionalPromptValue, setAdditionalPromptValue] = React.useState(''); + const handleAdditionalPromptChange = (newValue: string) => { + setAdditionalPromptValue(newValue); + }; + const [loading, setLoading] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + setLoading(true); + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + const availabilityCheckbox_seven = + formData.get('availabilityCheckbox_seven') === 'on'; + const availabilityCheckbox_fourteen = + formData.get('availabilityCheckbox_fourteen') === 'on'; + const availabilityCheckbox_twenty = + formData.get('availabilityCheckbox_twenty') === 'on'; + + const availabilityArray: string[] = []; + if (availabilityCheckbox_seven) availabilityArray.push('7'); + if (availabilityCheckbox_fourteen) availabilityArray.push('14'); + if (availabilityCheckbox_twenty) availabilityArray.push('20'); + + const semesterArray: string[] = []; + semesterArray.push(...(await fetchClosestSemesters(1))); + + const coursesArray = selectedCourses; + let coursesMap: { [key: string]: string } = {}; + for (let i = 0; i < coursesArray.length; i++) { + coursesMap[coursesArray[i]] = 'applied'; + } + + const applicationData = { + firstname: formData.get('firstName') as string, + lastname: formData.get('lastName') as string, + email: formData.get('email') as string, + ufid: formData.get('ufid') as string, + phonenumber: formData.get('phone-number') as string, + gpa: formData.get('gpa-select') as string, + department: formData.get('department-select') as string, + degree: formData.get('degrees-radio-group') as string, + semesterstatus: formData.get('semstatus-radio-group') as string, + additionalprompt: additionalPromptValue, + nationality: nationality as string, + englishproficiency: 'NA', + position: 'Supervised Teaching', + available_hours: availabilityArray as string[], + available_semesters: semesterArray as string[], + courses: coursesMap, + qualifications: formData.get('qualifications-prompt') as string, + uid: userId, + date: current_date, + status: 'Submitted', + resume_link: formData.get('resumeLink') as string, + }; + + if (!applicationData.email.includes('ufl.edu')) { + toast.error('Please enter a valid ufl email!'); + setLoading(false); + return; + } else if (applicationData.firstname === '') { + toast.error('Please enter a valid first name!'); + setLoading(false); + return; + } else if (applicationData.lastname === '') { + toast.error('Please enter a valid last name!'); + setLoading(false); + return; + } else if (applicationData.ufid == '') { + toast.error('Please enter a valid ufid!'); + setLoading(false); + return; + } else if (applicationData.phonenumber === '') { + toast.error('Please enter a valid phone number!'); + setLoading(false); + return; + } else if ( + applicationData.degree === null || + applicationData.degree === '' + ) { + toast.error('Please select a degree!'); + setLoading(false); + return; + } else if ( + applicationData.department === null || + applicationData.department === '' + ) { + toast.error('Please select a department!'); + setLoading(false); + return; + } else if ( + applicationData.semesterstatus === null || + applicationData.semesterstatus === '' + ) { + toast.error('Please select a semester status!'); + setLoading(false); + return; + } else if ( + applicationData.resume_link === null || + applicationData.resume_link === '' + ) { + toast.error('Please provide a resume link!'); + setLoading(false); + return; + } else if (applicationData.available_hours.length == 0) { + toast.error('Please enter your available hours!'); + setLoading(false); + return; + } else if (applicationData.available_semesters.length == 0) { + toast.error('Please enter your available semesters!'); + setLoading(false); + return; + } else if (coursesArray.length == 0) { + toast.error('Please enter your courses!'); + setLoading(false); + return; + } else { + const toastId = toast.loading('Processing application', { + duration: 30000, + }); + await firebase.firestore().collection('assignments').doc(userId).delete(); + + const response = await fetch( + 'https://us-central1-courseconnect-c6a7b.cloudfunctions.net/processApplicationForm', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(applicationData), + } + ); + + if (response.ok) { + toast.dismiss(toastId); + toast.success('Application submitted!'); + await UpdateRole(userId, 'student_applied'); + router.push('/'); + } else { + toast.dismiss(toastId); + toast.error('Application data failed to send to server!'); + console.log('ERROR: Application data failed to send to server'); + } + setLoading(false); + } + }; + + const [success, setSuccess] = React.useState(false); + const Alert = React.forwardRef(function Alert( + props, + ref + ) { + return ; + }); + + const handleSuccess = ( + event?: React.SyntheticEvent | Event, + reason?: string + ) => { + if (reason === 'clickaway') return; + setSuccess(false); + }; + + const [selectedCourses, setSelectedCourses] = React.useState([]); + const [names, setNames] = useState([]); + + React.useEffect(() => { + async function fetchData() { + try { + let data: string[] = []; + let visibleSems: string[] = await fetchClosestSemesters(1); + await firebase + .firestore() + .collection('semesters') + .doc(visibleSems[0]) + .collection('courses') + .get() + .then((snapshot) => + snapshot.docs.map((doc) => { + if (visibleSems.includes(doc.data().semester)) { + data.push(doc.id); + } + }) + ); + setNames(data); + } catch (err) { + console.log(err); + } + } + fetchData(); + }, []); + + const courseOptions = parseCoursesMinimal(names); + + return ( + <> + + + + + Application submitted successfully! + + + + + + + + Personal Information + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Supervision Information + +
+ + + + Please list the course(s) for which you are interested in + supervised teaching. Ensure that you select the courses with + your desired semester and instructor. + +
+ + + multiple + disableCloseOnSelect + options={courseOptions.sort( + (a, b) => -b.code.localeCompare(a.code) + )} + groupBy={(o) => o.department} + getOptionLabel={(o) => o.name} + value={courseOptions.filter((o) => + selectedCourses.includes(o.value) + )} + onChange={(_, vals) => + setSelectedCourses(vals.map((v) => v.value)) + } + isOptionEqualToValue={(opt, val) => opt.value === val.value} + renderInput={(params) => ( + + )} + /> + +
+ + + Please provide your most recently calculated cumulative UF + GPA. + +
+ +
+ + + Please upload a google drive link to your resume. + + + + + + Please describe your qualifications for supervised teaching, + including any teaching, grading, or tutoring experience. If + applicable, mention courses and instructors you have worked + with. + + + +
+ + +
+
+
+ + ); +} diff --git a/src/hooks/useGetItems.ts b/src/hooks/useGetItems.ts index 243fac0..14113a1 100644 --- a/src/hooks/useGetItems.ts +++ b/src/hooks/useGetItems.ts @@ -95,6 +95,12 @@ export const getApplications = (userRole: Role): NavbarItem[] => { icon: FolderOutlinedIcon, type: 'ta', }, + { + label: 'Supervised Teaching', + to: '/applications/supervisedTeaching', + icon: FolderOutlinedIcon, + type: 'supervised-teaching', + }, ]; /* ────────────────────────────────── Faculty ────────────────────────────────── */ diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 4e7c13c..4185820 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -1,6 +1,6 @@ import { SvgIconComponent } from '@mui/icons-material'; -export type CardType = 'research' | 'ta'; +export type CardType = 'research' | 'ta' | 'supervised-teaching'; export type QueryParams = Record; export type NavbarItem = { From a84b833b9882a948c2c31b5ba657d8e532bd0fdc Mon Sep 17 00:00:00 2001 From: ThomasOli Date: Sat, 31 Jan 2026 16:20:06 -0500 Subject: [PATCH 2/3] added --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb3a957..66361ae 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ courseconnect-c6a7b-firebase-adminsdk-dqqis-af57e2e045.json playwright-report test-results playwright/.auth -playwright.accounts.json \ No newline at end of file +playwright.accounts.json +nul From 2527c0a5cae7faa6ffa63d94f6a91b3b9e744556 Mon Sep 17 00:00:00 2001 From: ThomasOli Date: Sun, 8 Feb 2026 13:41:41 -0500 Subject: [PATCH 3/3] add new schema for supervised teaching and applications --- functions/src/index.ts | 106 ++- src/app/applications/courseAssistant/page.tsx | 1 + .../applications/supervisedTeaching/page.tsx | 656 +++++++++--------- .../applications/applicationRepository.ts | 428 ++++++++++++ src/scripts/migrateApplications.ts | 252 +++++++ 5 files changed, 1099 insertions(+), 344 deletions(-) create mode 100644 src/firebase/applications/applicationRepository.ts create mode 100644 src/scripts/migrateApplications.ts diff --git a/functions/src/index.ts b/functions/src/index.ts index 7bbb291..0d25bda 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -135,7 +135,7 @@ export const processSignUpForm = functions.https.onRequest( ); export const processApplicationForm = functions.https.onRequest( - (request, response) => { + async (request, response) => { response.set('Access-Control-Allow-Origin', '*'); response.set('Access-Control-Allow-Methods', 'GET, POST'); response.set('Access-Control-Allow-Headers', 'Content-Type'); @@ -143,45 +143,83 @@ export const processApplicationForm = functions.https.onRequest( if (request.method === 'OPTIONS') { // Handle preflight request response.status(204).send(''); - } else { - // Handle other requests + return; + } + + try { + // Determine application type (supervised_teaching sends this, course_assistant defaults) + const applicationType = + request.body.application_type || 'course_assistant'; + const uid = request.body.uid; + + if (!uid) { + response.status(400).send('Error: uid is required'); + return; + } // Extract user object data from post request const applicationObject = { - firstname: request.body.firstname, - lastname: request.body.lastname, - email: request.body.email, - ufid: request.body.ufid, - phonenumber: request.body.phonenumber, - gpa: request.body.gpa, - department: request.body.department, - degree: request.body.degree, - semesterstatus: request.body.semesterstatus, - additionalprompt: request.body.additionalprompt, - nationality: request.body.nationality, - englishproficiency: 'NA', - position: request.body.position, - available_hours: request.body.available_hours, - available_semesters: request.body.available_semesters, - courses: request.body.courses, - qualifications: request.body.qualifications, - uid: request.body.uid, - date: request.body.date, - status: request.body.status, - resume_link: request.body.resume_link, - classnumbers: 'NA', + ...request.body, + application_type: applicationType, }; - // Create the document within the "applications" collection - db.collection('applications') - .doc(applicationObject.uid) - .set(applicationObject) - .then(() => { - response.status(200).send('Application created successfully'); - }) - .catch((error: any) => { - response.send('Error creating application: ' + error.message); + // For course_assistant, ensure all expected fields are present + if (applicationType === 'course_assistant') { + applicationObject.phonenumber = request.body.phonenumber; + applicationObject.gpa = request.body.gpa; + applicationObject.department = request.body.department; + applicationObject.degree = request.body.degree; + applicationObject.semesterstatus = request.body.semesterstatus; + applicationObject.additionalprompt = request.body.additionalprompt; + applicationObject.nationality = request.body.nationality; + applicationObject.englishproficiency = 'NA'; + applicationObject.position = request.body.position; + applicationObject.available_hours = request.body.available_hours; + applicationObject.available_semesters = + request.body.available_semesters; + applicationObject.courses = request.body.courses; + applicationObject.qualifications = request.body.qualifications; + applicationObject.resume_link = request.body.resume_link; + applicationObject.classnumbers = 'NA'; + } + + // Common fields for all application types + applicationObject.firstname = request.body.firstname; + applicationObject.lastname = request.body.lastname; + applicationObject.email = request.body.email; + applicationObject.ufid = request.body.ufid; + applicationObject.uid = uid; + applicationObject.date = request.body.date; + applicationObject.status = request.body.status; + + // DUAL-WRITE MODE: Write to both old and new structures for backward compatibility + + // 1. Write to new structure: applications/{userId}/{applicationType}/{auto-generated-id} + const newStructureRef = await db + .collection('applications') + .doc(uid) + .collection(applicationType) + .add({ + ...applicationObject, + created_at: admin.firestore.FieldValue.serverTimestamp(), + updated_at: admin.firestore.FieldValue.serverTimestamp(), }); + + console.log( + `Created application in new structure: ${newStructureRef.id}` + ); + + // 2. Also write to old flat structure for backward compatibility (temporary) + // This allows existing code to continue working during migration + await db + .collection('applications') + .doc(uid) + .set(applicationObject, { merge: true }); + + response.status(200).send('Application created successfully'); + } catch (error: any) { + console.error('Error creating application:', error); + response.status(500).send('Error creating application: ' + error.message); } } ); diff --git a/src/app/applications/courseAssistant/page.tsx b/src/app/applications/courseAssistant/page.tsx index 9a783b8..e79941e 100644 --- a/src/app/applications/courseAssistant/page.tsx +++ b/src/app/applications/courseAssistant/page.tsx @@ -168,6 +168,7 @@ export default function Application() { // extract the specific user data from the form data into a parsable object const applicationData = { + application_type: 'course_assistant', firstname: formData.get('firstName') as string, lastname: formData.get('lastName') as string, email: formData.get('email') as string, diff --git a/src/app/applications/supervisedTeaching/page.tsx b/src/app/applications/supervisedTeaching/page.tsx index ad278db..7e21059 100644 --- a/src/app/applications/supervisedTeaching/page.tsx +++ b/src/app/applications/supervisedTeaching/page.tsx @@ -1,173 +1,148 @@ 'use client'; + import * as React from 'react'; import Button from '@mui/material/Button'; import CssBaseline from '@mui/material/CssBaseline'; import TextField from '@mui/material/TextField'; import Grid from '@mui/material/Grid'; import Box from '@mui/material/Box'; -import DepartmentSelect from '@/component/FormUtil/DepartmentSelect'; -import GPA_Select from '@/component/FormUtil/GPASelect'; import Typography from '@mui/material/Typography'; -import DegreeSelect from '@/component/FormUtil/DegreeSelect'; -import SemesterStatusSelect from '@/component/FormUtil/SemesterStatusSelect'; -import AvailabilityCheckbox from '@/component/FormUtil/AvailabilityCheckbox'; -import AdditionalSemesterPrompt from '@/component/FormUtil/AddtlSemesterPrompt'; -import UpdateRole from '@/firebase/util/UpdateUserRole'; import { useAuth } from '@/firebase/auth/auth_context'; import { Toaster, toast } from 'react-hot-toast'; import Snackbar from '@mui/material/Snackbar'; import MuiAlert, { AlertProps } from '@mui/material/Alert'; -import 'firebase/firestore'; +import HeaderCard from '@/components/HeaderCard/HeaderCard'; import firebase from '@/firebase/firebase_config'; -import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { fetchClosestSemesters } from '@/hooks/useSemesterOptions'; +import MenuItem from '@mui/material/MenuItem'; +import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; -import Autocomplete from '@mui/material/Autocomplete'; -import HeaderCard from '@/components/HeaderCard/HeaderCard'; -import { - fetchClosestSemesters, - parseCoursesMinimal, -} from '@/hooks/useSemesterOptions'; -import { CourseOption } from '@/hooks/useSemesterOptions'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; export default function SupervisedTeachingApplication() { - const router = useRouter(); const { user } = useAuth(); - const userId = user.uid; + const userId = user?.uid || ''; + const router = useRouter(); - const current = new Date(); - const current_date = `${ - current.getMonth() + 1 - }-${current.getDate()}-${current.getFullYear()}`; + const [success, setSuccess] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + // Teaching choices (dropdowns) + const [teachingFirstChoice, setTeachingFirstChoice] = React.useState(''); + const [teachingSecondChoice, setTeachingSecondChoice] = React.useState(''); + const [teachingThirdChoice, setTeachingThirdChoice] = React.useState(''); - const [nationality, setNationality] = React.useState(null); - const [additionalPromptValue, setAdditionalPromptValue] = React.useState(''); - const handleAdditionalPromptChange = (newValue: string) => { - setAdditionalPromptValue(newValue); + const Alert = React.forwardRef(function Alert( + props, + ref + ) { + return ; + }); + + const handleSuccess = ( + event?: React.SyntheticEvent | Event, + reason?: string + ) => { + if (reason === 'clickaway') return; + setSuccess(false); }; - const [loading, setLoading] = useState(false); const handleSubmit = async (event: React.FormEvent) => { - setLoading(true); event.preventDefault(); + setLoading(true); + const formData = new FormData(event.currentTarget); - const availabilityCheckbox_seven = - formData.get('availabilityCheckbox_seven') === 'on'; - const availabilityCheckbox_fourteen = - formData.get('availabilityCheckbox_fourteen') === 'on'; - const availabilityCheckbox_twenty = - formData.get('availabilityCheckbox_twenty') === 'on'; - - const availabilityArray: string[] = []; - if (availabilityCheckbox_seven) availabilityArray.push('7'); - if (availabilityCheckbox_fourteen) availabilityArray.push('14'); - if (availabilityCheckbox_twenty) availabilityArray.push('20'); - - const semesterArray: string[] = []; - semesterArray.push(...(await fetchClosestSemesters(1))); - - const coursesArray = selectedCourses; - let coursesMap: { [key: string]: string } = {}; - for (let i = 0; i < coursesArray.length; i++) { - coursesMap[coursesArray[i]] = 'applied'; - } + const firstName = (formData.get('firstName') as string) || ''; + const lastName = (formData.get('lastName') as string) || ''; + const ufid = (formData.get('ufid') as string) || ''; + const email = (formData.get('email') as string) || ''; + const confirmEmail = (formData.get('confirmEmail') as string) || ''; + const phdAdmissionTerm = (formData.get('phdAdmissionTerm') as string) || ''; + const phdAdvisor = (formData.get('phdAdvisor') as string) || ''; + const admittedToCandidacy = + (formData.get('admittedToCandidacy') as string) || ''; + const registerTerm = (formData.get('registerTerm') as string) || ''; + const previouslyRegistered = + (formData.get('previouslyRegistered') as string) || ''; + const previousDetails = (formData.get('previousDetails') as string) || ''; + const coursesComfortable = + (formData.get('coursesComfortable') as string) || ''; + // teaching choices come from Select state + const teachingFirst = teachingFirstChoice || ''; + const teachingSecond = teachingSecondChoice || ''; + const teachingThird = teachingThirdChoice || ''; + const captcha = formData.get('captcha') === 'on'; - const applicationData = { - firstname: formData.get('firstName') as string, - lastname: formData.get('lastName') as string, - email: formData.get('email') as string, - ufid: formData.get('ufid') as string, - phonenumber: formData.get('phone-number') as string, - gpa: formData.get('gpa-select') as string, - department: formData.get('department-select') as string, - degree: formData.get('degrees-radio-group') as string, - semesterstatus: formData.get('semstatus-radio-group') as string, - additionalprompt: additionalPromptValue, - nationality: nationality as string, - englishproficiency: 'NA', - position: 'Supervised Teaching', - available_hours: availabilityArray as string[], - available_semesters: semesterArray as string[], - courses: coursesMap, - qualifications: formData.get('qualifications-prompt') as string, - uid: userId, - date: current_date, - status: 'Submitted', - resume_link: formData.get('resumeLink') as string, - }; - - if (!applicationData.email.includes('ufl.edu')) { - toast.error('Please enter a valid ufl email!'); - setLoading(false); - return; - } else if (applicationData.firstname === '') { - toast.error('Please enter a valid first name!'); - setLoading(false); - return; - } else if (applicationData.lastname === '') { - toast.error('Please enter a valid last name!'); - setLoading(false); - return; - } else if (applicationData.ufid == '') { - toast.error('Please enter a valid ufid!'); - setLoading(false); - return; - } else if (applicationData.phonenumber === '') { - toast.error('Please enter a valid phone number!'); + // basic validations + if (!email.includes('ufl.edu')) { + toast.error('Please enter a valid GatorLink (ufl.edu) email'); setLoading(false); return; - } else if ( - applicationData.degree === null || - applicationData.degree === '' - ) { - toast.error('Please select a degree!'); - setLoading(false); - return; - } else if ( - applicationData.department === null || - applicationData.department === '' - ) { - toast.error('Please select a department!'); - setLoading(false); - return; - } else if ( - applicationData.semesterstatus === null || - applicationData.semesterstatus === '' - ) { - toast.error('Please select a semester status!'); + } + if (email !== confirmEmail) { + toast.error('Email and Confirm Email must match'); setLoading(false); return; - } else if ( - applicationData.resume_link === null || - applicationData.resume_link === '' - ) { - toast.error('Please provide a resume link!'); + } + if (!firstName || !lastName || !ufid) { + toast.error('Please complete all required personal fields'); setLoading(false); return; - } else if (applicationData.available_hours.length == 0) { - toast.error('Please enter your available hours!'); + } + if (!registerTerm) { + toast.error('Please select the term you want to register for EEL 6940'); setLoading(false); return; - } else if (applicationData.available_semesters.length == 0) { - toast.error('Please enter your available semesters!'); + } + if (!coursesComfortable) { + toast.error('Please list at least one course you could teach'); setLoading(false); return; - } else if (coursesArray.length == 0) { - toast.error('Please enter your courses!'); + } + if (!captcha) { + toast.error('Please complete the CAPTCHA confirmation'); setLoading(false); return; - } else { - const toastId = toast.loading('Processing application', { - duration: 30000, - }); - await firebase.firestore().collection('assignments').doc(userId).delete(); + } + // date + const current = new Date(); + const current_date = `${ + current.getMonth() + 1 + }-${current.getDate()}-${current.getFullYear()}`; + + const applicationData = { + application_type: 'supervised_teaching', + firstname: firstName, + lastname: lastName, + ufid, + email, + phdAdmissionTerm, + phdAdvisor, + admittedToCandidacy, + registerTerm, + previouslyRegistered, + previousDetails, + coursesComfortable, + teachingFirst, + teachingSecond, + teachingThird, + uid: userId, + date: current_date, + status: 'Submitted', + }; + + try { + const toastId = toast.loading('Submitting application...'); const response = await fetch( 'https://us-central1-courseconnect-c6a7b.cloudfunctions.net/processApplicationForm', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify(applicationData), } ); @@ -175,264 +150,325 @@ export default function SupervisedTeachingApplication() { if (response.ok) { toast.dismiss(toastId); toast.success('Application submitted!'); - await UpdateRole(userId, 'student_applied'); + setSuccess(true); + // optional: update role or navigate router.push('/'); } else { toast.dismiss(toastId); - toast.error('Application data failed to send to server!'); - console.log('ERROR: Application data failed to send to server'); + toast.error('Submission failed. Please try again later.'); } - setLoading(false); + } catch (err) { + console.error(err); + toast.error('Submission failed. Please try again later.'); } - }; - const [success, setSuccess] = React.useState(false); - const Alert = React.forwardRef(function Alert( - props, - ref - ) { - return ; - }); - - const handleSuccess = ( - event?: React.SyntheticEvent | Event, - reason?: string - ) => { - if (reason === 'clickaway') return; - setSuccess(false); + setLoading(false); }; - const [selectedCourses, setSelectedCourses] = React.useState([]); - const [names, setNames] = useState([]); - + const [visibleSems, setVisibleSems] = React.useState([]); React.useEffect(() => { - async function fetchData() { - try { - let data: string[] = []; - let visibleSems: string[] = await fetchClosestSemesters(1); - await firebase - .firestore() - .collection('semesters') - .doc(visibleSems[0]) - .collection('courses') - .get() - .then((snapshot) => - snapshot.docs.map((doc) => { - if (visibleSems.includes(doc.data().semester)) { - data.push(doc.id); - } - }) - ); - setNames(data); - } catch (err) { - console.log(err); - } + async function load() { + const sems = await fetchClosestSemesters(3); + setVisibleSems(sems); } - fetchData(); + load(); }, []); - const courseOptions = parseCoursesMinimal(names); - return ( - <> - - - - - Application submitted successfully! - - - - + + + + + + + ECE Ph.D. students may register for EEL 6940 Supervised Teaching to + fulfill professional development requirements. Deadlines: + +
    +
  • Fall Semester — August 2
  • +
  • Spring Semester — January 2
  • +
  • Summer Semester — April 30
  • +
+ + - - - Personal Information - - -
+ + + - - - + - + - + - -
- - Supervision Information - -
- - - - Please list the course(s) for which you are interested in - supervised teaching. Ensure that you select the courses with - your desired semester and instructor. + + + Admitted to candidacy? (registered for EEL 7980 hours) -
- - - multiple - disableCloseOnSelect - options={courseOptions.sort( - (a, b) => -b.code.localeCompare(a.code) - )} - groupBy={(o) => o.department} - getOptionLabel={(o) => o.name} - value={courseOptions.filter((o) => - selectedCourses.includes(o.value) - )} - onChange={(_, vals) => - setSelectedCourses(vals.map((v) => v.value)) - } - isOptionEqualToValue={(opt, val) => opt.value === val.value} - renderInput={(params) => ( - - )} - /> - +
+ + + +
- - - Please provide your most recently calculated cumulative UF - GPA. + + + + Select term + {visibleSems.map((s) => ( + + {s} + + ))} + + + + + + Previously registered for EEL 6940? -
- +
+ + + +
+ - - Please upload a google drive link to your resume. - + - - Please describe your qualifications for supervised teaching, - including any teaching, grading, or tutoring experience. If - applicable, mention courses and instructors you have worked - with. - -
- + + + + Teaching First Choice + + + + + + + + + Teaching Second Choice + + + + + + + + + Teaching Third Choice + + + + + + + + + + + + +
-
-
- + + + + + + Application submitted successfully! + + + ); } diff --git a/src/firebase/applications/applicationRepository.ts b/src/firebase/applications/applicationRepository.ts new file mode 100644 index 0000000..8712246 --- /dev/null +++ b/src/firebase/applications/applicationRepository.ts @@ -0,0 +1,428 @@ +// Application Repository with backward compatibility +// Handles reading/writing applications from both old flat structure and new sub-collection structure + +import { + collection, + doc, + getDoc, + getDocs, + setDoc, + addDoc, + updateDoc, + query, + where, + collectionGroup, + Firestore, + FieldValue, + serverTimestamp, + runTransaction, +} from 'firebase/firestore'; + +export type ApplicationType = 'course_assistant' | 'supervised_teaching'; + +export interface ApplicationParent { + firstname: string; + lastname: string; + email: string; + ufid: string; + application_type: ApplicationType | 'multi'; + latest_type: ApplicationType; + updated_at: any; + has_course_assistant?: boolean; + has_supervised_teaching?: boolean; + created_at?: any; +} + +export interface BaseApplicationData { + firstname: string; + lastname: string; + email: string; + ufid: string; + uid: string; + date: string; + status: string; + application_type?: ApplicationType; +} + +export interface CourseAssistantApplication extends BaseApplicationData { + application_type: 'course_assistant'; + phonenumber: string; + gpa: string; + department: string; + degree: string; + semesterstatus: string; + additionalprompt: string; + nationality: string; + englishproficiency: string; + position: string; + available_hours: string[]; + available_semesters: string[]; + courses: { + [courseKey: string]: 'applied' | 'approved' | 'denied' | 'accepted'; + }; + qualifications: string; + resume_link: string; +} + +export interface SupervisedTeachingApplication extends BaseApplicationData { + application_type: 'supervised_teaching'; + phdAdmissionTerm: string; + phdAdvisor: string; + admittedToCandidacy: string; + registerTerm: string; + previouslyRegistered: string; + previousDetails: string; + coursesComfortable: string; + teachingFirst: string; + teachingSecond: string; + teachingThird: string; +} + +export type ApplicationData = + | CourseAssistantApplication + | SupervisedTeachingApplication; + +export class ApplicationRepository { + private db: Firestore; + + constructor(db: Firestore) { + this.db = db; + } + + /** + * Get a user's most recent application by type + * Returns the latest application based on date field + */ + async getLatestApplication( + userId: string, + type: ApplicationType + ): Promise { + const applications = await this.getUserApplications(userId, type); + if (applications.length === 0) return null; + + // Sort by date descending and return the most recent + applications.sort((a, b) => { + const dateA = new Date(a.date).getTime(); + const dateB = new Date(b.date).getTime(); + return dateB - dateA; + }); + + return applications[0]; + } + + /** + * Get all applications for a user of a specific type + */ + async getUserApplications( + userId: string, + type: ApplicationType + ): Promise { + const applications: ApplicationData[] = []; + + // Check new sub-collection structure + const typeCollectionRef = collection(this.db, 'applications', userId, type); + const typeSnapshot = await getDocs(typeCollectionRef); + + typeSnapshot.forEach((doc) => { + applications.push({ ...doc.data(), id: doc.id } as ApplicationData); + }); + + // Backward compatibility - check old flat structure if no new structure found + if (applications.length === 0) { + const oldRef = doc(this.db, 'applications', userId); + const oldDoc = await getDoc(oldRef); + + if (oldDoc.exists()) { + const data = oldDoc.data(); + const inferredType = this.inferApplicationType(data); + if (inferredType === type) { + applications.push({ ...data, id: userId } as ApplicationData); + } + } + } + + return applications; + } + + /** + * Get a specific application by ID + */ + async getApplicationById( + userId: string, + type: ApplicationType, + applicationId: string + ): Promise { + // Try new structure + const appRef = doc(this.db, 'applications', userId, type, applicationId); + const appDoc = await getDoc(appRef); + + if (appDoc.exists()) { + return { ...appDoc.data(), id: appDoc.id } as ApplicationData; + } + + // Backward compatibility - if applicationId equals userId, check old flat structure + if (applicationId === userId) { + const oldRef = doc(this.db, 'applications', userId); + const oldDoc = await getDoc(oldRef); + + if (oldDoc.exists()) { + const data = oldDoc.data(); + const inferredType = this.inferApplicationType(data); + if (inferredType === type) { + return { ...data, id: userId } as ApplicationData; + } + } + } + + return null; + } + + /** + * Get all applications for a specific type across all users + * Uses collectionGroup query for new structure, falls back to collection query for old + */ + async getAllApplicationsByType( + type: ApplicationType + ): Promise { + const applications: ApplicationData[] = []; + + // Query new structure using collectionGroup + // This queries all sub-collections named 'course_assistant' or 'supervised_teaching' across all users + const typesQuery = collectionGroup(this.db, type); + const typesSnapshot = await getDocs(typesQuery); + + typesSnapshot.forEach((doc) => { + // Get userId from parent path: applications/{userId}/{type}/{applicationId} + const userId = doc.ref.parent.parent?.id; + applications.push({ + ...doc.data(), + id: doc.id, + userId: userId, // Include userId for reference + } as ApplicationData); + }); + + // Also query old flat structure for backward compatibility + const flatQuery = query( + collection(this.db, 'applications'), + where('application_type', '==', type) + ); + const flatSnapshot = await getDocs(flatQuery); + + flatSnapshot.forEach((doc) => { + // Only add if not already in results (check by document ID) + if (!applications.some((app) => app.id === doc.id)) { + applications.push({ ...doc.data(), id: doc.id } as ApplicationData); + } + }); + + // For old data without application_type field, infer from schema + if (type === 'course_assistant') { + const legacySnapshot = await getDocs(collection(this.db, 'applications')); + legacySnapshot.forEach((doc) => { + const data = doc.data(); + // Infer course_assistant if has courses field and not already in results + if ( + data.courses && + !data.application_type && + !applications.some((app) => app.id === doc.id) + ) { + applications.push({ ...data, id: doc.id } as ApplicationData); + } + }); + } + + return applications; + } + + /** + * Save an application (creates new document with auto-generated ID in sub-collection) + * Returns the auto-generated application ID + */ + async saveApplication( + userId: string, + type: ApplicationType, + data: ApplicationData + ): Promise { + // Create reference to sub-collection with auto-generated ID + const typeCollectionRef = collection(this.db, 'applications', userId, type); + + // Add document with auto-generated ID + const docRef = await addDoc(typeCollectionRef, { + ...data, + application_type: type, + uid: userId, // Ensure userId is stored in document + created_at: serverTimestamp(), + updated_at: serverTimestamp(), + }); + + return docRef.id; + } + + /** + * Update an existing application + */ + async updateApplication( + userId: string, + type: ApplicationType, + applicationId: string, + updates: Partial + ): Promise { + const appRef = doc(this.db, 'applications', userId, type, applicationId); + + await updateDoc(appRef, { + ...updates, + updated_at: serverTimestamp(), + }); + } + + /** + * Update course status for a specific course assistant application (atomic transaction) + */ + async updateCourseStatus( + userId: string, + applicationId: string, + courseKey: string, + status: 'applied' | 'approved' | 'denied' | 'accepted' + ): Promise { + await runTransaction(this.db, async (tx) => { + // Check new structure first + const newRef = doc( + this.db, + 'applications', + userId, + 'course_assistant', + applicationId + ); + const newSnap = await tx.get(newRef); + + if (newSnap.exists()) { + // Update in new structure + tx.update(newRef, { + [`courses.${courseKey}`]: status, + updated_at: serverTimestamp(), + }); + } else { + // Fallback to old structure (applicationId should equal userId) + const oldRef = doc(this.db, 'applications', userId); + const oldSnap = await tx.get(oldRef); + if (!oldSnap.exists()) { + throw new Error('Application not found'); + } + tx.update(oldRef, { [`courses.${courseKey}`]: status }); + } + }); + } + + /** + * Update course status for the LATEST course assistant application + * (convenience method for backward compatibility) + */ + async updateCourseStatusLatest( + userId: string, + courseKey: string, + status: 'applied' | 'approved' | 'denied' | 'accepted' + ): Promise { + const latestApp = await this.getLatestApplication( + userId, + 'course_assistant' + ); + + if (!latestApp) { + throw new Error('No course assistant application found for user'); + } + + await this.updateCourseStatus(userId, latestApp.id, courseKey, status); + } + + /** + * Update application status field for a specific application + */ + async updateApplicationStatus( + userId: string, + type: ApplicationType, + applicationId: string, + status: string + ): Promise { + await runTransaction(this.db, async (tx) => { + // Check new structure first + const newRef = doc(this.db, 'applications', userId, type, applicationId); + const newSnap = await tx.get(newRef); + + if (newSnap.exists()) { + // Update in new structure + tx.update(newRef, { + status, + updated_at: serverTimestamp(), + }); + } else { + // Fallback to old structure + const oldRef = doc(this.db, 'applications', userId); + const oldSnap = await tx.get(oldRef); + if (!oldSnap.exists()) { + throw new Error('Application not found'); + } + tx.update(oldRef, { status }); + } + }); + } + + /** + * Check if user has any applications of a specific type + */ + async hasApplication( + userId: string, + type?: ApplicationType + ): Promise { + if (type) { + const apps = await this.getUserApplications(userId, type); + return apps.length > 0; + } + + // Check if user has any applications at all + const courseAssistantApps = await this.getUserApplications( + userId, + 'course_assistant' + ); + const supervisedTeachingApps = await this.getUserApplications( + userId, + 'supervised_teaching' + ); + + return courseAssistantApps.length > 0 || supervisedTeachingApps.length > 0; + } + + /** + * Infer application type from document data (for backward compatibility) + */ + private inferApplicationType(data: any): ApplicationType { + if (data.application_type) return data.application_type; + + // Infer based on schema: course_assistant has courses object + return data.courses ? 'course_assistant' : 'supervised_teaching'; + } + + /** + * Get applications for a specific course with status filtering + * (Used by course application views) + */ + async getApplicationsForCourse( + courseKey: string, + statuses: string[] + ): Promise { + const applications: ApplicationData[] = []; + + // This is specific to course_assistant applications only + // Query all course_assistant applications + const allApps = await this.getAllApplicationsByType('course_assistant'); + + // Filter by course and status + const filtered = allApps.filter((app) => { + if (app.application_type !== 'course_assistant') return false; + const courseAssistantApp = app as CourseAssistantApplication; + const courseStatus = courseAssistantApp.courses?.[courseKey]; + return courseStatus && statuses.includes(courseStatus); + }); + + return filtered; + } +} diff --git a/src/scripts/migrateApplications.ts b/src/scripts/migrateApplications.ts new file mode 100644 index 0000000..e5ef322 --- /dev/null +++ b/src/scripts/migrateApplications.ts @@ -0,0 +1,252 @@ +/** + * Migration script to move applications from flat structure to sub-collection structure + * + * Old structure: applications/{userId} - all fields in one document + * New structure: applications/{userId}/types/{applicationType} - parent + sub-collections + * + * Usage: + * npm run migrate:applications // Dry run (shows what would be migrated) + * npm run migrate:applications --execute // Actually perform migration + * npm run migrate:applications --execute --delete-old // Migrate and delete old data + * npm run migrate:applications --execute --overwrite // Overwrite existing sub-collection docs + */ + +import * as admin from 'firebase-admin'; + +const DRY = !process.argv.includes('--execute'); +const DELETE_OLD = process.argv.includes('--delete-old'); +const OVERWRITE = process.argv.includes('--overwrite'); +const BATCH_SIZE = 400; + +type AnyMap = Record; +type ApplicationType = 'course_assistant' | 'supervised_teaching'; + +interface MigrationStats { + total: number; + migrated: number; + skipped: number; + existed: number; + deleted: number; + errors: number; +} + +function init() { + if (!admin.apps.length) { + admin.initializeApp({ credential: admin.credential.applicationDefault() }); + } + return admin.firestore(); +} + +/** + * Infer application type from document data + */ +function inferApplicationType(data: AnyMap): ApplicationType { + // If application_type field exists, use it + if (data.application_type) { + return data.application_type as ApplicationType; + } + + // Infer based on schema: + // - course_assistant has 'courses' object field + // - supervised_teaching has 'phdAdvisor' field + if (data.courses && typeof data.courses === 'object') { + return 'course_assistant'; + } + + if (data.phdAdvisor) { + return 'supervised_teaching'; + } + + // Default to course_assistant if can't determine + console.warn( + `Could not infer type for document, defaulting to course_assistant` + ); + return 'course_assistant'; +} + +// No longer need parent document fields - each application is independent + +/** + * Migrate a single application document + */ +async function migrateApplication( + db: admin.firestore.Firestore, + userId: string, + data: AnyMap, + stats: MigrationStats +): Promise { + try { + const applicationType = inferApplicationType(data); + + // Check if already migrated by querying the sub-collection + if (!OVERWRITE && !DRY) { + const existingApps = await db + .collection('applications') + .doc(userId) + .collection(applicationType) + .where('date', '==', data.date) // Check if same application date exists + .limit(1) + .get(); + + if (!existingApps.empty) { + stats.existed++; + console.log( + ` [EXISTS] ${userId} - ${applicationType} (date: ${data.date}) already migrated` + ); + return; + } + } + + // Prepare application data with timestamps + const applicationData = { + ...data, + application_type: applicationType, + created_at: + data.created_at || admin.firestore.FieldValue.serverTimestamp(), + updated_at: admin.firestore.FieldValue.serverTimestamp(), + }; + + if (DRY) { + console.log(` [DRY] Would migrate: ${userId} as ${applicationType}`); + console.log( + ` - Path: applications/${userId}/${applicationType}/{auto-id}` + ); + console.log(` - Date: ${data.date}`); + stats.migrated++; + } else { + // Write to new sub-collection structure with auto-generated ID + const newAppRef = await db + .collection('applications') + .doc(userId) + .collection(applicationType) + .add(applicationData); + + stats.migrated++; + console.log( + ` [MIGRATED] ${userId} - ${applicationType} (ID: ${newAppRef.id})` + ); + + // Optionally delete old flat document if requested + if (DELETE_OLD) { + await db.collection('applications').doc(userId).delete(); + stats.deleted++; + } + } + } catch (error: any) { + stats.errors++; + console.error(` [ERROR] Failed to migrate ${userId}:`, error.message); + } +} + +/** + * Main migration function + */ +async function migrate() { + console.log('='.repeat(60)); + console.log('Application Migration Script'); + console.log('='.repeat(60)); + console.log(`Mode: ${DRY ? 'DRY RUN (no changes will be made)' : 'EXECUTE'}`); + console.log(`Delete old data: ${DELETE_OLD ? 'YES' : 'NO'}`); + console.log(`Overwrite existing: ${OVERWRITE ? 'YES' : 'NO'}`); + console.log(`Batch size: ${BATCH_SIZE}`); + console.log('='.repeat(60)); + console.log(''); + + const db = init(); + const srcCol = db.collection('applications'); + + const stats: MigrationStats = { + total: 0, + migrated: 0, + skipped: 0, + existed: 0, + deleted: 0, + errors: 0, + }; + + let lastId: string | null = null; + let done = false; + + while (!done) { + let q = srcCol + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastId) { + q = q.startAfter(lastId); + } + + const snap = await q.get(); + + if (snap.empty) { + break; + } + + console.log(`Processing batch of ${snap.size} documents...`); + + for (const doc of snap.docs) { + stats.total++; + const data = doc.data() as AnyMap; + const userId = doc.id; + + // Skip if document is missing essential fields + if (!data.firstname || !data.email || !data.ufid) { + stats.skipped++; + console.warn(` [SKIP] ${userId} - missing essential fields`); + continue; + } + + // Skip if document appears to be a new structure marker (no actual application data) + if (!data.date && !data.status) { + stats.skipped++; + console.warn( + ` [SKIP] ${userId} - appears to be metadata doc, not an application` + ); + continue; + } + + await migrateApplication(db, userId, data, stats); + } + + lastId = snap.docs[snap.docs.length - 1].id; + if (snap.size < BATCH_SIZE) { + done = true; + } + + console.log(''); // Empty line between batches + } + + // Print summary + console.log('='.repeat(60)); + console.log('Migration Summary'); + console.log('='.repeat(60)); + console.log(`Total documents processed: ${stats.total}`); + console.log(`Successfully migrated: ${stats.migrated}`); + console.log(`Already existed: ${stats.existed}`); + console.log(`Skipped: ${stats.skipped}`); + console.log(`Errors: ${stats.errors}`); + if (DELETE_OLD) { + console.log(`Deleted old documents: ${stats.deleted}`); + } + console.log('='.repeat(60)); + + if (DRY) { + console.log(''); + console.log('This was a DRY RUN - no changes were made.'); + console.log('Run with --execute flag to perform actual migration.'); + } else { + console.log(''); + console.log('Migration complete!'); + if (!DELETE_OLD) { + console.log( + 'Old data has been preserved. Run with --delete-old to remove it.' + ); + } + } +} + +// Run migration +migrate().catch((error) => { + console.error('Migration failed:', error); + process.exit(1); +});