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) + +
+ + + +
+
+ + + + Select term + {visibleSems.map((s) => ( + + {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 = {