From f48258317624a9a0d6b603a720b28c351ec394e3 Mon Sep 17 00:00:00 2001 From: ryaken-nakamoto Date: Tue, 11 Nov 2025 00:37:54 -0500 Subject: [PATCH 1/2] mock data + reviews --- apps/backend/scripts/README.md | 48 +++ apps/backend/scripts/register-paths.ts | 49 +++ apps/backend/scripts/seed-mock-data.ts | 353 ++++++++++++++++++ apps/backend/scripts/seed.config.json | 8 + .../src/applications/applications.service.ts | 5 + apps/backend/src/applications/utils.ts | 34 +- apps/backend/src/reviews/reviews.service.ts | 16 +- apps/backend/src/users/users.controller.ts | 12 +- .../components/ApplicationTables/index.tsx | 5 +- .../individualApplication.tsx | 27 +- .../pages/ApplicationDetailPage.tsx | 39 +- .../src/shared/hooks/useApplicationDetails.ts | 63 ++++ apps/frontend/src/utils/semester.ts | 27 -- apps/shared/utils/cycle.ts | 30 ++ package.json | 3 +- 15 files changed, 637 insertions(+), 82 deletions(-) create mode 100644 apps/backend/scripts/README.md create mode 100644 apps/backend/scripts/register-paths.ts create mode 100644 apps/backend/scripts/seed-mock-data.ts create mode 100644 apps/backend/scripts/seed.config.json create mode 100644 apps/frontend/src/shared/hooks/useApplicationDetails.ts delete mode 100644 apps/frontend/src/utils/semester.ts create mode 100644 apps/shared/utils/cycle.ts diff --git a/apps/backend/scripts/README.md b/apps/backend/scripts/README.md new file mode 100644 index 00000000..b2f4a91e --- /dev/null +++ b/apps/backend/scripts/README.md @@ -0,0 +1,48 @@ +## Mock Data Seed Script + +The `seed-mock-data.ts` script populates the `public.users` and `public.applications` tables with exhaustive mock combinations so the recruitment UI has data to work with. It **wipes existing rows** in `reviews`, `fileuploads`, `applications`, and `users` before inserting fresh data, so only run it against disposable/local databases. + +### 1. Configure Your Personal User (Optional but requested) + +If you want the seed data to include your own contact information, edit `apps/backend/scripts/seed.config.json`: + +```json +{ + "personalUser": { + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "status": "Admin" + } +} +``` + +- Only `firstName`, `lastName`, and `email` are required. +- `status` is optional (defaults to `Admin` if omitted). +- Team, role, profile picture, GitHub, and LinkedIn fields are intentionally left blank for every seed user. +- The script always creates 3 admins, 10 recruiters, and 50 applicants; your personal user is added on top of those counts. +- Remove the `personalUser` block if you do not want an extra record. + +### 2. Ensure Database Environment Variables + +From the repo root, make sure these variables are exported (or defined in `apps/backend/.env`, which the script auto-loads): + +- `NX_DB_HOST` +- `NX_DB_USERNAME` +- `NX_DB_PASSWORD` +- `NX_DB_DATABASE` (defaults to `c4c-ops` if missing) + +### 3. Run the Seed + +```bash +yarn seed:mock-data +``` + +This command uses `ts-node` with the backend tsconfig, connects via TypeORM, truncates dependent tables, and inserts: + +- A deterministic set of 3 admins, 10 recruiters, and 50 applicants built from predefined name pairs (with team/role/social columns left null), plus your optional personal user +- One Spring 2025 application per applicant (50 total) with stage, progress, review status, and position cycling through the enum values for variety + +### 4. Verify (Optional) + +Use any SQL client to inspect `public.users` and `public.applications`, or hit the existing API endpoints to confirm the data looks correct. If you need to reseed, rerun the command—it will clear and repopulate the tables each time. diff --git a/apps/backend/scripts/register-paths.ts b/apps/backend/scripts/register-paths.ts new file mode 100644 index 00000000..fdf437e0 --- /dev/null +++ b/apps/backend/scripts/register-paths.ts @@ -0,0 +1,49 @@ +import fs from 'fs'; +import path from 'path'; +import { register } from 'tsconfig-paths'; + +const candidateConfigs = [ + '../../../tsconfig.base.json', + '../../../tsconfig.json', + '../tsconfig.app.json', + '../tsconfig.json', +]; + +let registered = false; + +for (const relativePath of candidateConfigs) { + const configPath = path.resolve(__dirname, relativePath); + if (!fs.existsSync(configPath)) { + continue; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const { compilerOptions } = config; + if (!compilerOptions?.paths) { + continue; + } + + const baseDir = path.dirname(configPath); + const baseUrl = path.resolve(baseDir, compilerOptions.baseUrl ?? '.'); + + register({ + baseUrl, + paths: compilerOptions.paths, + }); + + registered = true; + break; + } catch (error) { + console.warn( + `Failed to register tsconfig paths from ${configPath}. Trying next candidate.`, + error, + ); + } +} + +if (!registered) { + console.warn( + 'Could not locate a tsconfig with path mappings; @shared/* imports may fail.', + ); +} diff --git a/apps/backend/scripts/seed-mock-data.ts b/apps/backend/scripts/seed-mock-data.ts new file mode 100644 index 00000000..febac1cb --- /dev/null +++ b/apps/backend/scripts/seed-mock-data.ts @@ -0,0 +1,353 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +import 'reflect-metadata'; +import './register-paths'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import path from 'path'; +import fs from 'fs'; + +import { User } from '../src/users/user.entity'; +import { Application } from '../src/applications/application.entity'; +import { Review } from '../src/reviews/review.entity'; +import { FileUpload } from '../src/file-upload/entities/file-upload.entity'; +import { PluralNamingStrategy } from '../src/strategies/plural-naming.strategy'; + +import { UserStatus } from '../../shared/types/user.types'; +import { + ApplicationStage, + Position, + Response, + ReviewStatus, + Semester, + StageProgress, +} from '../../shared/types/application.types'; + +interface UserSeedDescriptor { + status: UserStatus; + firstName: string; + lastName: string; +} + +interface SeedConfig { + personalUser?: PersonalUserConfig; +} + +interface PersonalUserConfig { + firstName: string; + lastName: string; + email: string; + status?: UserStatus; +} + +const FIRST_NAMES = [ + 'Alex', + 'Jordan', + 'Taylor', + 'Morgan', + 'Quinn', + 'Riley', + 'Casey', + 'Parker', +]; + +const LAST_NAMES = [ + 'Reyes', + 'Patel', + 'Nguyen', + 'Rivera', + 'Sanchez', + 'Kim', + 'Osei', + 'Dubois', + 'Khan', + 'Moreno', +]; + +const STATUS_COUNTS: Array<{ status: UserStatus; count: number }> = [ + { status: UserStatus.ADMIN, count: 3 }, + { status: UserStatus.RECRUITER, count: 10 }, + { status: UserStatus.APPLICANT, count: 50 }, +]; + +const APPLICATION_STAGES = Object.values(ApplicationStage); +const STAGE_PROGRESS_VALUES = Object.values(StageProgress); +const REVIEW_STATUS_VALUES = Object.values(ReviewStatus); +const POSITIONS = Object.values(Position); +const SEMESTER_DEFAULT = Semester.SPRING; +const YEAR_DEFAULT = 2026; + +async function main() { + const envLoaded = loadEnvIfPresent('../.env'); + if (envLoaded) { + console.log('Loaded environment variables from apps/backend/.env'); + } + + const config = loadConfig('./seed.config.json'); + if (config.personalUser) { + console.log('Personal seed user configured.'); + } + + ensureEnv([ + 'NX_DB_HOST', + 'NX_DB_USERNAME', + 'NX_DB_PASSWORD', + // database name optional, defaults below + ]); + + const dataSourceOptions: DataSourceOptions = { + type: 'postgres', + host: process.env.NX_DB_HOST, + port: Number(process.env.NX_DB_PORT ?? 5432), + username: process.env.NX_DB_USERNAME, + password: process.env.NX_DB_PASSWORD, + database: process.env.NX_DB_DATABASE ?? 'c4c-ops', + entities: [User, Application, Review, FileUpload], + namingStrategy: new PluralNamingStrategy(), + synchronize: false, + logging: false, + }; + + const dataSource = new DataSource(dataSourceOptions); + + try { + await dataSource.initialize(); + console.log('Connected to database, wiping target tables...'); + + await clearExistingData(dataSource); + + const users = await seedUsers(dataSource, config.personalUser); + const applications = await seedApplications(dataSource, users); + + console.log( + `Seed complete. Inserted ${users.length} users and ${applications.length} applications.`, + ); + } catch (error) { + console.error('Mock data seed failed:', error); + process.exitCode = 1; + } finally { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + } +} + +async function clearExistingData(dataSource: DataSource) { + const reviewRepo = dataSource.getRepository(Review); + const fileUploadRepo = dataSource.getRepository(FileUpload); + const appRepo = dataSource.getRepository(Application); + const userRepo = dataSource.getRepository(User); + + // Order matters because of foreign-key constraints. + await reviewRepo.delete({}); + await fileUploadRepo.delete({}); + await appRepo.delete({}); + await userRepo.delete({}); +} + +async function seedUsers( + dataSource: DataSource, + personalUser?: PersonalUserConfig, +): Promise { + const userRepo = dataSource.getRepository(User); + const namePairs = buildNamePairs(); + const seeds: UserSeedDescriptor[] = []; + let nameCursor = 0; + + STATUS_COUNTS.forEach(({ status, count }) => { + for (let i = 0; i < count; i++) { + const pair = namePairs[nameCursor % namePairs.length]; + nameCursor += 1; + seeds.push({ + status, + firstName: pair.firstName, + lastName: pair.lastName, + }); + } + }); + + const users = seeds.map((seed, index) => + userRepo.create({ + status: seed.status, + firstName: seed.firstName, + lastName: seed.lastName, + email: buildEmail(seed.firstName, seed.lastName, index), + }), + ); + + if (personalUser) { + users.unshift( + userRepo.create({ + status: personalUser.status ?? UserStatus.ADMIN, + firstName: personalUser.firstName, + lastName: personalUser.lastName, + email: personalUser.email, + }), + ); + } + + console.log(`Generating ${users.length} user rows...`); + return userRepo.save(users); +} + +async function seedApplications( + dataSource: DataSource, + users: User[], +): Promise { + const appRepo = dataSource.getRepository(Application); + + const applicantUsers = users.filter( + (user) => user.status === UserStatus.APPLICANT, + ); + if (!applicantUsers.length) { + throw new Error('No applicant users available to attach applications.'); + } + + const recruiterIds = users + .filter((user) => user.status === UserStatus.RECRUITER) + .map((user) => user.id); + + const applications = applicantUsers.map((owner, index) => { + const position = POSITIONS[index % POSITIONS.length]; + const stage = APPLICATION_STAGES[index % APPLICATION_STAGES.length]; + const stageProgress = + STAGE_PROGRESS_VALUES[index % STAGE_PROGRESS_VALUES.length]; + const reviewStatus = + REVIEW_STATUS_VALUES[index % REVIEW_STATUS_VALUES.length]; + const semester = SEMESTER_DEFAULT; + const assignedRecruiterIds = + recruiterIds.length === 0 + ? [] + : buildAssignedRecruiters(recruiterIds, index); + const createdAt = new Date( + Date.UTC(YEAR_DEFAULT, (index * 3) % 12, (index % 27) + 1, 12, 0, 0), + ); + const response: Response[] = [ + { + question: 'Why do you want to work on this role?', + answer: `I am excited to contribute as a ${position} during the ${semester} semester.`, + }, + { + question: 'What stage best suits you right now?', + answer: `Currently focused on ${stage} with ${stageProgress} progress.`, + }, + ]; + + return appRepo.create({ + user: owner, + content: `Detailed cover letter for ${position} (${stage}).`, + createdAt, + year: YEAR_DEFAULT, + semester, + position, + stage, + stageProgress, + reviewStatus, + response, + assignedRecruiterIds, + }); + }); + + console.log(`Generating ${applications.length} application rows...`); + return appRepo.save(applications); +} + +function buildNamePairs(): Array<{ firstName: string; lastName: string }> { + const pairs: Array<{ firstName: string; lastName: string }> = []; + FIRST_NAMES.forEach((firstName) => { + LAST_NAMES.forEach((lastName) => { + pairs.push({ firstName, lastName }); + }); + }); + return pairs; +} + +function buildEmail( + firstName: string, + lastName: string, + index: number, +): string { + const sanitizedFirst = sanitizeForEmail(firstName); + const sanitizedLast = sanitizeForEmail(lastName); + const uniqueSuffix = index.toString().padStart(3, '0'); + return `${sanitizedFirst}.${sanitizedLast}${uniqueSuffix}@example.com`; +} + +function sanitizeForEmail(value: string): string { + const cleaned = value.toLowerCase().replace(/[^a-z0-9]/g, ''); + return cleaned || 'user'; +} + +function buildAssignedRecruiters(ids: number[], index: number): number[] { + const uniqueIds = new Set(); + const howMany = (index % 3) + 1; // assign between 1 and 3 recruiters where possible + + for (let offset = 0; offset < howMany; offset++) { + const recruiterId = ids[(index + offset * 2) % ids.length]; + uniqueIds.add(recruiterId); + if (uniqueIds.size === ids.length) { + break; + } + } + + return Array.from(uniqueIds); +} + +function loadEnvIfPresent(relativePath: string): boolean { + const envPath = path.resolve(__dirname, relativePath); + if (!fs.existsSync(envPath)) { + return false; + } + + const contents = fs.readFileSync(envPath, 'utf8'); + contents.split(/\r?\n/).forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + return; + } + + const [key, ...rest] = trimmed.split('='); + if (!key || !rest.length) { + return; + } + + if (process.env[key] !== undefined) { + return; + } + + const value = rest + .join('=') + .trim() + .replace(/^['"]|['"]$/g, ''); + process.env[key] = value; + }); + + return true; +} + +function loadConfig(relativePath: string): SeedConfig { + const configPath = path.resolve(__dirname, relativePath); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + return JSON.parse(raw); + } catch (error) { + console.warn( + `Failed to parse seed config at ${configPath}. Falling back to defaults.`, + error, + ); + return {}; + } +} + +function ensureEnv(requiredKeys: string[]) { + const missing = requiredKeys.filter((key) => !process.env[key]); + if (missing.length) { + throw new Error( + `Missing required database environment variables: ${missing.join(', ')}`, + ); + } +} + +main(); diff --git a/apps/backend/scripts/seed.config.json b/apps/backend/scripts/seed.config.json new file mode 100644 index 00000000..5c0c8672 --- /dev/null +++ b/apps/backend/scripts/seed.config.json @@ -0,0 +1,8 @@ +{ + "personalUser": { + "firstName": "Ryaken", + "lastName": "Nakamoto", + "email": "nakamoto.r@husky.neu.edu", + "status": "Admin" + } +} diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index a69a4257..9dc9318d 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, UnauthorizedException, Injectable, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -33,6 +34,8 @@ import { AssignedRecruiterDTO } from './dto/assigned-recruiter.dto'; @Injectable() export class ApplicationsService { + private readonly logger = new Logger(ApplicationsService.name); + constructor( @InjectRepository(Application) private readonly applicationsRepository: Repository, @@ -333,10 +336,12 @@ export class ApplicationsService { } async findAll(userId: number): Promise { + this.logger.debug(`Fetching all applications for user ${userId}`); const apps = await this.applicationsRepository.find({ where: { user: { id: userId } }, relations: ['user', 'reviews'], }); + this.logger.debug(`Found ${apps.length} applications for user ${userId}`); return apps; } diff --git a/apps/backend/src/applications/utils.ts b/apps/backend/src/applications/utils.ts index 88290875..731c93cb 100644 --- a/apps/backend/src/applications/utils.ts +++ b/apps/backend/src/applications/utils.ts @@ -1,36 +1,46 @@ +import { Logger } from '@nestjs/common'; import { Application } from './application.entity'; import { Cycle } from './dto/cycle'; import { Semester } from '../../../shared/types/application.types'; +import { + getRecruitmentCycle, + getRecruitmentSemester, + getRecruitmentYear, +} from '@shared/utils/cycle'; -export const getCurrentSemester = (): Semester => { - const month: number = new Date().getMonth(); - if (month >= 1 && month <= 7) { - return Semester.FALL; // We will be recruiting for the fall semester during Feb - Aug - } - return Semester.SPRING; // We will be recruiting for the spring semester during Sep - Jan -}; +const logger = new Logger('ApplicationsUtils'); -export const getCurrentYear = (): number => { - return new Date().getFullYear(); -}; +export const getCurrentSemester = (): Semester => getRecruitmentSemester(); -export const getCurrentCycle = () => - new Cycle(getCurrentYear(), getCurrentSemester()); +export const getCurrentYear = (): number => getRecruitmentYear(); + +export const getCurrentCycle = () => { + const { year, semester } = getRecruitmentCycle(); + return new Cycle(year, semester); +}; export const getAppForCurrentCycle = ( applications: Application[], ): Application | null => { if (applications.length === 0) { + logger.debug('No applications provided when determining current cycle'); return null; } const currentCycle = getCurrentCycle(); + logger.debug( + `Looking for application in current cycle (year=${currentCycle.year}, semester=${currentCycle.semester}) among ${applications.length} records`, + ); for (const application of applications) { const cycle = new Cycle(application.year, application.semester); if (cycle.isCurrentCycle(currentCycle)) { + logger.debug( + `Found application ${application.id} matching current cycle`, + ); return application; } } + logger.debug('No application matched the current cycle'); return null; }; diff --git a/apps/backend/src/reviews/reviews.service.ts b/apps/backend/src/reviews/reviews.service.ts index 28401420..95eda1ce 100644 --- a/apps/backend/src/reviews/reviews.service.ts +++ b/apps/backend/src/reviews/reviews.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Review } from './review.entity'; @@ -8,6 +8,8 @@ import { SubmitReviewRequestDTO } from './dto/submit-review.request.dto'; @Injectable() export class ReviewsService { + private readonly logger = new Logger(ReviewsService.name); + constructor( @InjectRepository(Review) private reviewsRepository: Repository, @@ -21,9 +23,15 @@ export class ReviewsService { currentUser: User, createReviewDTO: SubmitReviewRequestDTO, ): Promise { + this.logger.debug( + `User ${currentUser.id} submitting review for applicant ${createReviewDTO.applicantId} at stage ${createReviewDTO.stage}`, + ); const application = await this.applicationsService.findCurrent( createReviewDTO.applicantId, ); + this.logger.debug( + `Resolved current application ${application.id} for applicant ${createReviewDTO.applicantId}`, + ); const review = this.reviewsRepository.create({ reviewerId: currentUser.id, @@ -35,6 +43,10 @@ export class ReviewsService { stage: createReviewDTO.stage, }); - return this.reviewsRepository.save(review); + const savedReview = await this.reviewsRepository.save(review); + this.logger.debug( + `Review ${savedReview.id} saved for applicant ${createReviewDTO.applicantId}`, + ); + return savedReview; } } diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 6e6abea1..36df3397 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -77,7 +77,17 @@ export class UsersController { @Param('userId', ParseIntPipe) userId: number, @Request() req, ): Promise { - const user = await this.usersService.findUserById(req.user.id); + const canViewOtherUser = + req.user.status === UserStatus.ADMIN || + req.user.status === UserStatus.RECRUITER; + + if (!canViewOtherUser && req.user.id !== userId) { + throw new UnauthorizedException( + 'You do not have permission to view this user', + ); + } + + const user = await this.usersService.findUserById(userId); return toGetUserResponseDto(user); } diff --git a/apps/frontend/src/features/applications/components/ApplicationTables/index.tsx b/apps/frontend/src/features/applications/components/ApplicationTables/index.tsx index 50598994..bd69df22 100644 --- a/apps/frontend/src/features/applications/components/ApplicationTables/index.tsx +++ b/apps/frontend/src/features/applications/components/ApplicationTables/index.tsx @@ -9,7 +9,10 @@ import { } from '@sharedTypes/types/application.types'; import { applicationColumns } from './columns'; import useLoginContext from '@features/auth/components/LoginPage/useLoginContext'; -import { getCurrentSemester, getCurrentYear } from '@utils/semester'; +import { + getRecruitmentSemester as getCurrentSemester, + getRecruitmentYear as getCurrentYear, +} from '@sharedTypes/utils/cycle'; import { defaultPaginationModel, defaultPageSizeOptions, diff --git a/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx b/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx index ba9115e5..d29cff66 100644 --- a/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx +++ b/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx @@ -34,6 +34,7 @@ type IndividualApplicationDetailsProps = { selectedApplication: Application; selectedUser: User; accessToken: string; + onRefreshApplication?: () => Promise; }; interface ReviewerInfo { @@ -44,6 +45,7 @@ const IndividualApplicationDetails = ({ selectedApplication, selectedUser, accessToken, + onRefreshApplication, }: IndividualApplicationDetailsProps) => { // Lighter purple accent tuned to match Figma palette const ACCENT = '#9B6CFF'; @@ -75,19 +77,26 @@ const IndividualApplicationDetails = ({ const handleFormSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!selectedUser || ((!reviewRating || !reviewComment) && !decision)) { - alert('Please provide both a rating and comment, or a decision.'); + const trimmedComment = reviewComment.trim(); + + if ( + !selectedUser || + ((!reviewRating || trimmedComment === '') && !decision) + ) { + alert( + 'Please provide both a rating and a non-empty comment, or make a decision.', + ); return; } try { // Submit review - if (reviewRating && reviewComment) { + if (reviewRating && trimmedComment) { await apiClient.submitReview(accessToken, { applicantId: selectedUser.id, stage: selectedApplication.stage, rating: reviewRating, - content: reviewComment, + content: trimmedComment, }); } @@ -98,6 +107,10 @@ const IndividualApplicationDetails = ({ alert('Submitted successfully!'); + if (onRefreshApplication) { + await onRefreshApplication(); + } + // Reset form setReviewRating(null); setReviewComment(''); @@ -303,7 +316,9 @@ const IndividualApplicationDetails = ({ {isAdmin ? ( ) : ( @@ -514,4 +529,4 @@ const IndividualApplicationDetails = ({ ); }; -export default IndividualApplicationDetails; \ No newline at end of file +export default IndividualApplicationDetails; diff --git a/apps/frontend/src/features/applications/pages/ApplicationDetailPage.tsx b/apps/frontend/src/features/applications/pages/ApplicationDetailPage.tsx index 2cc98a5c..59fe7cb4 100644 --- a/apps/frontend/src/features/applications/pages/ApplicationDetailPage.tsx +++ b/apps/frontend/src/features/applications/pages/ApplicationDetailPage.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Navigate, useParams } from 'react-router-dom'; import { Container } from '@mui/material'; -import { Application } from '@sharedTypes/types/application.types'; -import { User } from '@sharedTypes/types/user.types'; import useLoginContext from '@features/auth/components/LoginPage/useLoginContext'; import IndividualApplicationDetails from '@features/applications/components/ApplicationTables/individualApplication'; -import apiClient from '@api/apiClient'; +import { useApplicationDetails } from '@shared/hooks/useApplicationDetails'; const IndividualApplication: React.FC = () => { const { token: accessToken } = useLoginContext(); @@ -14,34 +12,10 @@ const IndividualApplication: React.FC = () => { const userIdString = params.userIdString || params.userId || params.id; const userId = parseInt(userIdString || ''); - const [application, setApplication] = useState(null); - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // Fetches application and user information to be passed into IndividualApplicationDetails - const fetchData = async () => { - if (!userId || isNaN(userId) || !accessToken) { - setIsLoading(false); - return; - } - try { - const [application, user] = await Promise.all([ - apiClient.getApplication(accessToken, userId), - apiClient.getUserById(accessToken, userId), - ]); - - setApplication(application); - setUser(user); - setIsLoading(false); - } catch (error) { - console.error('Error fetching data:', error); - setIsLoading(false); - } - }; - - fetchData(); - }, [accessToken, userId]); + const { application, user, isLoading, refetch } = useApplicationDetails( + accessToken, + isNaN(userId) ? null : userId, + ); if (isLoading) { return ( @@ -61,6 +35,7 @@ const IndividualApplication: React.FC = () => { selectedApplication={application} selectedUser={user} accessToken={accessToken} + onRefreshApplication={refetch} /> ); diff --git a/apps/frontend/src/shared/hooks/useApplicationDetails.ts b/apps/frontend/src/shared/hooks/useApplicationDetails.ts new file mode 100644 index 00000000..932ba916 --- /dev/null +++ b/apps/frontend/src/shared/hooks/useApplicationDetails.ts @@ -0,0 +1,63 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Application } from '@sharedTypes/types/application.types'; +import { User } from '@sharedTypes/types/user.types'; +import apiClient from '@api/apiClient'; + +interface UseApplicationDetailsResult { + application: Application | null; + user: User | null; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Fetches both the application and user details for a given userId. + * Provides a refetch function so consumers can refresh (e.g., after submitting a review). + */ +export const useApplicationDetails = ( + accessToken: string | null, + userId: number | null, +): UseApplicationDetailsResult => { + const [application, setApplication] = useState(null); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!accessToken || !userId) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + const [app, userData] = await Promise.all([ + apiClient.getApplication(accessToken, userId), + apiClient.getUserById(accessToken, userId), + ]); + setApplication(app); + setUser(userData); + } catch (err) { + const errorInstance = + err instanceof Error ? err : new Error('Failed to fetch application'); + setError(errorInstance); + console.error('Error fetching application details:', err); + } finally { + setIsLoading(false); + } + }, [accessToken, userId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { + application, + user, + isLoading, + error, + refetch: fetchData, + }; +}; diff --git a/apps/frontend/src/utils/semester.ts b/apps/frontend/src/utils/semester.ts deleted file mode 100644 index 591c6f1f..00000000 --- a/apps/frontend/src/utils/semester.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Semester } from '@sharedTypes/types/application.types'; - -/** - * Gets the current recruitment semester based on the current date. - * - * Recruitment periods: - * - Fall semester: February - August (months 1-7) - * - Spring semester: September - January (months 8-12, 0) - * - * @returns The current recruitment semester - */ -export const getCurrentSemester = (): Semester => { - const month: number = new Date().getMonth(); - if (month >= 1 && month <= 7) { - return Semester.FALL; - } - return Semester.SPRING; -}; - -/** - * Gets the current year. - * - * @returns The current year - */ -export const getCurrentYear = (): number => { - return new Date().getFullYear(); -}; diff --git a/apps/shared/utils/cycle.ts b/apps/shared/utils/cycle.ts new file mode 100644 index 00000000..94d1ab1a --- /dev/null +++ b/apps/shared/utils/cycle.ts @@ -0,0 +1,30 @@ +import { Semester } from '../types/application.types'; + +/** + * Recruitment for the fall semester (onboarding in the fall term) happens + * during February through August. All other months (September through January) + * are part of the spring recruitment window. + */ +export const getRecruitmentSemester = (date: Date = new Date()): Semester => { + const month = date.getMonth(); + if (month >= 1 && month <= 7) { + return Semester.FALL; + } + return Semester.SPRING; +}; + +/** + * Determines the recruitment year based on the month. + * When we are in the spring recruitment window (September - January), + * we are recruiting for the upcoming spring class, so we advance the year. + */ +export const getRecruitmentYear = (date: Date = new Date()): number => { + const baseYear = date.getFullYear(); + const semester = getRecruitmentSemester(date); + return semester === Semester.SPRING ? baseYear + 1 : baseYear; +}; + +export const getRecruitmentCycle = (date: Date = new Date()) => ({ + year: getRecruitmentYear(date), + semester: getRecruitmentSemester(date), +}); diff --git a/package.json b/package.json index 376381eb..d66d280b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "prepare": "husky install", "test": "jest", "test:watch": "jest --watch", - "test:cov": "jest --coverage" + "test:cov": "jest --coverage", + "seed:mock-data": "ts-node --project apps/backend/tsconfig.app.json apps/backend/scripts/seed-mock-data.ts" }, "private": true, "dependencies": { From 6dddf90217060561ee289aa49383c563c6fe93bb Mon Sep 17 00:00:00 2001 From: ryaken-nakamoto Date: Tue, 11 Nov 2025 21:37:43 -0500 Subject: [PATCH 2/2] added more mock functionality + fixed file upload bug + file upload ENUM --- apps/backend/scripts/README.md | 1 + apps/backend/scripts/seed-mock-data.ts | 46 ++++++++++++++++- .../entities/file-upload.entity.ts | 4 ++ .../file-upload.controller.spec.ts | 31 +++++++---- .../src/file-upload/file-upload.controller.ts | 9 +++- .../file-upload/file-upload.service.spec.ts | 51 ++++++++++++------- .../src/file-upload/file-upload.service.ts | 32 ++++++++++-- apps/frontend/src/api/apiClient.ts | 3 ++ .../components/ApplicantView/user.tsx | 12 ++++- .../applicant/components/FileUploadBox.tsx | 10 +++- apps/shared/types/file-upload.types.ts | 4 ++ 11 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 apps/shared/types/file-upload.types.ts diff --git a/apps/backend/scripts/README.md b/apps/backend/scripts/README.md index b2f4a91e..b916a62b 100644 --- a/apps/backend/scripts/README.md +++ b/apps/backend/scripts/README.md @@ -21,6 +21,7 @@ If you want the seed data to include your own contact information, edit `apps/ba - `status` is optional (defaults to `Admin` if omitted). - Team, role, profile picture, GitHub, and LinkedIn fields are intentionally left blank for every seed user. - The script always creates 3 admins, 10 recruiters, and 50 applicants; your personal user is added on top of those counts. +- When `personalUser` is defined, the script appends a hardcoded application tied to that user so you can exercise the applicant experience with your own account (even if they already qualify as an `Applicant`). - Remove the `personalUser` block if you do not want an extra record. ### 2. Ensure Database Environment Variables diff --git a/apps/backend/scripts/seed-mock-data.ts b/apps/backend/scripts/seed-mock-data.ts index febac1cb..e71cf005 100644 --- a/apps/backend/scripts/seed-mock-data.ts +++ b/apps/backend/scripts/seed-mock-data.ts @@ -115,7 +115,11 @@ async function main() { await clearExistingData(dataSource); const users = await seedUsers(dataSource, config.personalUser); - const applications = await seedApplications(dataSource, users); + const applications = await seedApplications( + dataSource, + users, + config.personalUser, + ); console.log( `Seed complete. Inserted ${users.length} users and ${applications.length} applications.`, @@ -191,13 +195,20 @@ async function seedUsers( async function seedApplications( dataSource: DataSource, users: User[], + personalUserConfig?: PersonalUserConfig, ): Promise { const appRepo = dataSource.getRepository(Application); + const personalUser = + personalUserConfig && + users.find( + (user) => + user.email?.toLowerCase() === personalUserConfig.email.toLowerCase(), + ); const applicantUsers = users.filter( (user) => user.status === UserStatus.APPLICANT, ); - if (!applicantUsers.length) { + if (!applicantUsers.length && !personalUser) { throw new Error('No applicant users available to attach applications.'); } @@ -246,6 +257,37 @@ async function seedApplications( }); }); + if (personalUser) { + const hardcodedApplication = appRepo.create({ + user: personalUser, + content: `Personal application seed for ${personalUser.firstName} ${personalUser.lastName}.`, + createdAt: new Date(Date.UTC(YEAR_DEFAULT, 0, 15, 12, 0, 0)), + year: YEAR_DEFAULT, + semester: SEMESTER_DEFAULT, + position: Position.PM, + stage: ApplicationStage.PM_CHALLENGE, + stageProgress: StageProgress.PENDING, + reviewStatus: ReviewStatus.UNASSIGNED, + response: [ + { + question: 'Who owns this application?', + answer: `${personalUser.firstName} ${personalUser.lastName} (${personalUser.email}).`, + }, + { + question: 'Why is this record seeded?', + answer: + 'This hardcoded row is appended for the personal user defined in seed.config.json.', + }, + ], + assignedRecruiterIds: + recruiterIds.length === 0 + ? [] + : buildAssignedRecruiters(recruiterIds, applications.length), + }); + + applications.push(hardcodedApplication); + } + console.log(`Generating ${applications.length} application rows...`); return appRepo.save(applications); } diff --git a/apps/backend/src/file-upload/entities/file-upload.entity.ts b/apps/backend/src/file-upload/entities/file-upload.entity.ts index ebaf7678..bcc080c9 100644 --- a/apps/backend/src/file-upload/entities/file-upload.entity.ts +++ b/apps/backend/src/file-upload/entities/file-upload.entity.ts @@ -1,5 +1,6 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; import { Application } from '../../applications/application.entity'; +import { FilePurpose } from '@shared/types/file-upload.types'; @Entity() export class FileUpload { @@ -18,6 +19,9 @@ export class FileUpload { @Column({ type: 'bytea' }) // For PostgreSQL binary data file_data: Buffer; + @Column({ type: 'enum', enum: FilePurpose }) + purpose: FilePurpose; + @ManyToOne(() => Application, (application) => application.attachments, { onDelete: 'CASCADE', }) diff --git a/apps/backend/src/file-upload/file-upload.controller.spec.ts b/apps/backend/src/file-upload/file-upload.controller.spec.ts index a7617f53..629970fb 100644 --- a/apps/backend/src/file-upload/file-upload.controller.spec.ts +++ b/apps/backend/src/file-upload/file-upload.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FileUploadController } from './file-upload.controller'; import { FileUploadService } from './file-upload.service'; import { BadRequestException } from '@nestjs/common'; +import { FilePurpose } from '@shared/types/file-upload.types'; describe('FileUploadController', () => { let controller: FileUploadController; @@ -36,14 +37,26 @@ describe('FileUploadController', () => { it('should call service.handleFileUpload and return result', async () => { const result = { message: 'File uploaded successfully', fileId: 1 }; mockService.handleFileUpload.mockResolvedValue(result); - const response = await controller.uploadFile(mockFile, 123); - expect(service.handleFileUpload).toHaveBeenCalledWith(mockFile, 123); + const response = await controller.uploadFile( + mockFile, + 123, + FilePurpose.PM_CHALLENGE, + ); + expect(service.handleFileUpload).toHaveBeenCalledWith( + mockFile, + 123, + FilePurpose.PM_CHALLENGE, + ); expect(response).toEqual(result); }); it('should throw BadRequestException if applicationId is missing', async () => { await expect( - controller.uploadFile(mockFile, undefined as unknown as number), + controller.uploadFile( + mockFile, + undefined as unknown as number, + FilePurpose.PM_CHALLENGE, + ), ).rejects.toThrow(BadRequestException); }); @@ -57,17 +70,17 @@ describe('FileUploadController', () => { mockService.handleFileUpload.mockImplementation(() => { throw new BadRequestException('Invalid file type'); }); - await expect(controller.uploadFile(invalidTypeFile, 123)).rejects.toThrow( - BadRequestException, - ); + await expect( + controller.uploadFile(invalidTypeFile, 123, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(BadRequestException); // File too large const largeFile = { ...mockFile, size: 13 * 1024 * 1024 }; mockService.handleFileUpload.mockImplementation(() => { throw new BadRequestException('File is too large!'); }); - await expect(controller.uploadFile(largeFile, 123)).rejects.toThrow( - BadRequestException, - ); + await expect( + controller.uploadFile(largeFile, 123, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(BadRequestException); }); }); diff --git a/apps/backend/src/file-upload/file-upload.controller.ts b/apps/backend/src/file-upload/file-upload.controller.ts index 3195822e..1704c665 100644 --- a/apps/backend/src/file-upload/file-upload.controller.ts +++ b/apps/backend/src/file-upload/file-upload.controller.ts @@ -8,10 +8,12 @@ import { Query, Get, ParseIntPipe, + Body, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileUploadService } from './file-upload.service'; import 'multer'; +import { FilePurpose } from '@shared/types/file-upload.types'; @Controller('file-upload') export class FileUploadController { @@ -22,12 +24,17 @@ export class FileUploadController { async uploadFile( @UploadedFile() file: Express.Multer.File, @Param('applicationId') applicationId: number, + @Body('purpose') purpose: FilePurpose, ) { if (!applicationId) { throw new BadRequestException('Application ID is required'); } console.log('Received file in controller:', file); - return this.fileUploadService.handleFileUpload(file, applicationId); + return this.fileUploadService.handleFileUpload( + file, + applicationId, + purpose, + ); } @Get('user/:userId') diff --git a/apps/backend/src/file-upload/file-upload.service.spec.ts b/apps/backend/src/file-upload/file-upload.service.spec.ts index e75bb467..83691945 100644 --- a/apps/backend/src/file-upload/file-upload.service.spec.ts +++ b/apps/backend/src/file-upload/file-upload.service.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { FileUpload } from './entities/file-upload.entity'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ApplicationsService } from '../applications/applications.service'; +import { FilePurpose } from '@shared/types/file-upload.types'; const mockFileRepository = () => ({ create: jest.fn(), @@ -11,7 +12,7 @@ const mockFileRepository = () => ({ }); const mockApplicationsService = { - findCurrent: jest.fn(), + findOne: jest.fn(), }; describe('FileUploadService', () => { @@ -52,10 +53,14 @@ describe('FileUploadService', () => { const applicationId = 123; const fakeApplication = { id: applicationId }; const fakeFile = { id: 1 }; - mockApplicationsService.findCurrent.mockResolvedValue(fakeApplication); + mockApplicationsService.findOne.mockResolvedValue(fakeApplication); fileRepository.create.mockReturnValue(fakeFile); fileRepository.save.mockResolvedValue(fakeFile); - const result = await service.handleFileUpload(mockFile, applicationId); + const result = await service.handleFileUpload( + mockFile, + applicationId, + FilePurpose.PM_CHALLENGE, + ); expect(result).toHaveProperty('message', 'File uploaded successfully'); expect(result).toHaveProperty('fileId', 1); }); @@ -70,17 +75,25 @@ describe('FileUploadService', () => { const applicationId = 123; const fakeApplication = { id: applicationId }; const fakeFile = { id: 1 }; - mockApplicationsService.findCurrent.mockResolvedValue(fakeApplication); + mockApplicationsService.findOne.mockResolvedValue(fakeApplication); fileRepository.create.mockReturnValue(fakeFile); fileRepository.save.mockResolvedValue(fakeFile); - const result = await service.handleFileUpload(mockFile, applicationId); + const result = await service.handleFileUpload( + mockFile, + applicationId, + FilePurpose.PM_CHALLENGE, + ); expect(result).toHaveProperty('message', 'File uploaded successfully'); expect(result).toHaveProperty('fileId', 1); }); it('should throw if no file is uploaded', async () => { await expect( - service.handleFileUpload(undefined as unknown as Express.Multer.File, 1), + service.handleFileUpload( + undefined as unknown as Express.Multer.File, + 1, + FilePurpose.PM_CHALLENGE, + ), ).rejects.toThrow(BadRequestException); }); @@ -91,9 +104,9 @@ describe('FileUploadService', () => { size: 1024, buffer: Buffer.from('test'), } as Express.Multer.File; - await expect(service.handleFileUpload(mockFile, 1)).rejects.toThrow( - BadRequestException, - ); + await expect( + service.handleFileUpload(mockFile, 1, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(BadRequestException); }); it('should throw if file is too large', async () => { @@ -103,9 +116,9 @@ describe('FileUploadService', () => { size: 13 * 1024 * 1024, // 13MB buffer: Buffer.from('test'), } as Express.Multer.File; - await expect(service.handleFileUpload(mockFile, 1)).rejects.toThrow( - BadRequestException, - ); + await expect( + service.handleFileUpload(mockFile, 1, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(BadRequestException); }); it('should throw if application not found', async () => { @@ -115,10 +128,10 @@ describe('FileUploadService', () => { size: 1024, buffer: Buffer.from('test'), } as Express.Multer.File; - mockApplicationsService.findCurrent.mockResolvedValue(undefined); - await expect(service.handleFileUpload(mockFile, 1)).rejects.toThrow( - NotFoundException, - ); + mockApplicationsService.findOne.mockResolvedValue(undefined); + await expect( + service.handleFileUpload(mockFile, 1, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(NotFoundException); }); it('should throw if file is too large and type is invalid', async () => { @@ -128,8 +141,8 @@ describe('FileUploadService', () => { size: 20 * 1024 * 1024, buffer: Buffer.from('test'), } as Express.Multer.File; - await expect(service.handleFileUpload(mockFile, 1)).rejects.toThrow( - BadRequestException, - ); + await expect( + service.handleFileUpload(mockFile, 1, FilePurpose.PM_CHALLENGE), + ).rejects.toThrow(BadRequestException); }); }); diff --git a/apps/backend/src/file-upload/file-upload.service.ts b/apps/backend/src/file-upload/file-upload.service.ts index 308cff70..8752490a 100644 --- a/apps/backend/src/file-upload/file-upload.service.ts +++ b/apps/backend/src/file-upload/file-upload.service.ts @@ -8,6 +8,7 @@ import { Repository } from 'typeorm'; import { FileUpload } from './entities/file-upload.entity'; import { ApplicationsService } from '../applications/applications.service'; import 'multer'; +import { FilePurpose } from '@shared/types/file-upload.types'; @Injectable() export class FileUploadService { @@ -17,11 +18,21 @@ export class FileUploadService { private readonly applicationsService: ApplicationsService, ) {} - async handleFileUpload(file: Express.Multer.File, applicationId: number) { + async handleFileUpload( + file: Express.Multer.File, + applicationId: number, + purpose: FilePurpose, + ) { console.log('Received file:', file); if (!file) { throw new BadRequestException('No file uploaded'); } + if (!purpose) { + throw new BadRequestException('File purpose is required'); + } + if (!Object.values(FilePurpose).includes(purpose)) { + throw new BadRequestException('Invalid file purpose'); + } // Validate file type const allowedMimeTypes = [ @@ -53,12 +64,17 @@ export class FileUploadService { size: file.size, // assuming size is passed in the request file_data: file.buffer, // the raw buffer from the request application: application, + purpose: purpose, }); await this.fileRepository.save(uploadedFile); console.log('File uploaded:', uploadedFile); - return { message: 'File uploaded successfully', fileId: uploadedFile.id }; + return { + message: 'File uploaded successfully', + fileId: uploadedFile.id, + purpose: uploadedFile.purpose, + }; } /** @@ -81,10 +97,19 @@ export class FileUploadService { 'file.filename', 'file.mimetype', 'file.size', + 'file.purpose', 'application.id', ]); } else { - queryBuilder.addSelect('file.file_data'); + queryBuilder.select([ + 'file.id', + 'file.filename', + 'file.mimetype', + 'file.size', + 'file.purpose', + 'file.file_data', + 'application.id', + ]); } const files = await queryBuilder.getMany(); @@ -104,6 +129,7 @@ export class FileUploadService { filename: file.filename, mimetype: file.mimetype, size: file.size, + purpose: file.purpose, ...(includeFileData && { file_data: file.file_data }), applicationId: file.application?.id, })), diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 7eeec872..26b94489 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -10,6 +10,7 @@ import type { } from '@sharedTypes/types/application.types'; import type { User } from '@sharedTypes/types/user.types'; import type { SubmitReviewRequest } from '@sharedTypes/dto/request/review.dto'; +import { FilePurpose } from '@sharedTypes/types/file-upload.types'; import { type TokenResponse, @@ -292,9 +293,11 @@ export class ApiClient { accessToken: string, applicationId: number, file: File, + purpose: FilePurpose, ): Promise<{ message: string; fileId: number }> { const formData = new FormData(); formData.append('file', file); + formData.append('purpose', purpose); return this.axiosInstance .post(`/api/file-upload/${applicationId}`, formData, { diff --git a/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx b/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx index 090aa7eb..deac29bc 100644 --- a/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx +++ b/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Typography, Stack, @@ -19,6 +19,7 @@ import { Application, ApplicationStage, } from '@sharedTypes/types/application.types'; +import { FilePurpose } from '@sharedTypes/types/file-upload.types'; interface ApplicantViewProps { user: User; @@ -33,6 +34,14 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => { const { fullName } = useFullName(accessToken); const [applicationId, setApplicationId] = useState(null); + useEffect(() => { + if (selectedApplication?.id) { + setApplicationId(selectedApplication.id); + } else { + setApplicationId(null); + } + }, [selectedApplication]); + return ( { )} diff --git a/apps/frontend/src/features/applicant/components/FileUploadBox.tsx b/apps/frontend/src/features/applicant/components/FileUploadBox.tsx index 42defdd0..095f2ff3 100644 --- a/apps/frontend/src/features/applicant/components/FileUploadBox.tsx +++ b/apps/frontend/src/features/applicant/components/FileUploadBox.tsx @@ -1,15 +1,18 @@ import React, { useState } from 'react'; import { Box, Typography, Button, Snackbar, Alert } from '@mui/material'; import apiClient from '@api/apiClient'; +import { FilePurpose } from '@sharedTypes/types/file-upload.types'; interface FileUploadBoxProps { accessToken: string; applicationId: number | null; + filePurpose: FilePurpose; } const FileUploadBox: React.FC = ({ accessToken, applicationId, + filePurpose, }) => { const [selectedFile, setSelectedFile] = useState(null); const [uploading, setUploading] = useState(false); @@ -61,7 +64,12 @@ const FileUploadBox: React.FC = ({ return; } - await apiClient.uploadFile(accessToken, applicationId, selectedFile); + await apiClient.uploadFile( + accessToken, + applicationId, + selectedFile, + filePurpose, + ); setToastMessage(`Uploaded: ${selectedFile.name}`); setToastSeverity('success'); diff --git a/apps/shared/types/file-upload.types.ts b/apps/shared/types/file-upload.types.ts new file mode 100644 index 00000000..33282f33 --- /dev/null +++ b/apps/shared/types/file-upload.types.ts @@ -0,0 +1,4 @@ +export enum FilePurpose { + RESUME = 'RESUME', + PM_CHALLENGE = 'PM_CHALLENGE', +}