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
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/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/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
new file mode 100644
index 0000000..7e21059
--- /dev/null
+++ b/src/app/applications/supervisedTeaching/page.tsx
@@ -0,0 +1,474 @@
+'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 Typography from '@mui/material/Typography';
+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 HeaderCard from '@/components/HeaderCard/HeaderCard';
+import firebase from '@/firebase/firebase_config';
+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 Select, { SelectChangeEvent } from '@mui/material/Select';
+
+export default function SupervisedTeachingApplication() {
+ const { user } = useAuth();
+ const userId = user?.uid || '';
+ const router = useRouter();
+
+ 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 Alert = React.forwardRef
(function Alert(
+ props,
+ ref
+ ) {
+ return ;
+ });
+
+ const handleSuccess = (
+ event?: React.SyntheticEvent | Event,
+ reason?: string
+ ) => {
+ if (reason === 'clickaway') return;
+ setSuccess(false);
+ };
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setLoading(true);
+
+ const formData = new FormData(event.currentTarget);
+
+ 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';
+
+ // basic validations
+ if (!email.includes('ufl.edu')) {
+ toast.error('Please enter a valid GatorLink (ufl.edu) email');
+ setLoading(false);
+ return;
+ }
+ if (email !== confirmEmail) {
+ toast.error('Email and Confirm Email must match');
+ setLoading(false);
+ return;
+ }
+ if (!firstName || !lastName || !ufid) {
+ toast.error('Please complete all required personal fields');
+ setLoading(false);
+ return;
+ }
+ if (!registerTerm) {
+ toast.error('Please select the term you want to register for EEL 6940');
+ setLoading(false);
+ return;
+ }
+ if (!coursesComfortable) {
+ toast.error('Please list at least one course you could teach');
+ setLoading(false);
+ return;
+ }
+ if (!captcha) {
+ toast.error('Please complete the CAPTCHA confirmation');
+ setLoading(false);
+ return;
+ }
+
+ // 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',
+ },
+ body: JSON.stringify(applicationData),
+ }
+ );
+
+ if (response.ok) {
+ toast.dismiss(toastId);
+ toast.success('Application submitted!');
+ setSuccess(true);
+ // optional: update role or navigate
+ router.push('/');
+ } else {
+ toast.dismiss(toastId);
+ toast.error('Submission failed. Please try again later.');
+ }
+ } catch (err) {
+ console.error(err);
+ toast.error('Submission failed. Please try again later.');
+ }
+
+ setLoading(false);
+ };
+
+ const [visibleSems, setVisibleSems] = React.useState([]);
+ React.useEffect(() => {
+ async function load() {
+ const sems = await fetchClosestSemesters(3);
+ setVisibleSems(sems);
+ }
+ load();
+ }, []);
+
+ return (
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Admitted to candidacy? (registered for EEL 7980 hours)
+
+
+
+
+
+
+
+
+
+
+
+ {visibleSems.map((s) => (
+
+ ))}
+
+
+
+
+
+ Previously registered for EEL 6940?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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/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/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);
+});
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 = {