From 36d58618255b097501565eede982ee24a7a36939 Mon Sep 17 00:00:00 2001 From: Naman Rusia Date: Fri, 21 Nov 2025 15:51:20 -0500 Subject: [PATCH 1/3] progress --- .../src/applications/applications.service.ts | 11 +- .../src/file-upload/file-upload.controller.ts | 20 + .../src/file-upload/file-upload.service.ts | 18 + apps/frontend/src/api/apiClient.ts | 16 +- .../individualApplication.tsx | 64 ++- .../applications/components/FileWidget.tsx | 416 ++++++++++++++++++ 6 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/features/applications/components/FileWidget.tsx diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 44c8cd17..73e102b6 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -409,7 +409,7 @@ export class ApplicationsService { const allApplicationsDto = await Promise.all( applications.map(async (app) => { const ratings = this.calculateAllRatings(app.reviews); - const stageProgress = this.determineStageProgress(app, app.reviews); + const stageProgress = this.determineStageProgress(app, app.reviews); const assignedRecruiters = await this.getAssignedRecruitersForApplication(app); @@ -500,7 +500,10 @@ export class ApplicationsService { * submitted a review for that stage. If no recruiters are assigned, the * stage remains PENDING even if admins or others submit reviews. */ - private determineStageProgress(app: Application, reviews: any[]): StageProgress { + private determineStageProgress( + app: Application, + reviews: any[], + ): StageProgress { const stage = app.stage; // Terminal stages are always completed @@ -530,7 +533,9 @@ export class ApplicationsService { reviewerIdsForStage.has(id), ); - return allAssignedReviewed ? StageProgress.COMPLETED : StageProgress.PENDING; + return allAssignedReviewed + ? StageProgress.COMPLETED + : StageProgress.PENDING; } /** diff --git a/apps/backend/src/file-upload/file-upload.controller.ts b/apps/backend/src/file-upload/file-upload.controller.ts index 1704c665..29e98558 100644 --- a/apps/backend/src/file-upload/file-upload.controller.ts +++ b/apps/backend/src/file-upload/file-upload.controller.ts @@ -9,8 +9,11 @@ import { Get, ParseIntPipe, Body, + Res, + StreamableFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; import { FileUploadService } from './file-upload.service'; import 'multer'; import { FilePurpose } from '@shared/types/file-upload.types'; @@ -45,4 +48,21 @@ export class FileUploadController { const includeData = includeFileData === 'true'; return this.fileUploadService.getUserFiles(userId, includeData); } + + @Get('download/:fileId') + async downloadFile( + @Param('fileId', ParseIntPipe) fileId: number, + @Res({ passthrough: true }) res: Response, + ): Promise { + const file = await this.fileUploadService.getFileById(fileId); + + // Set response headers for file download + res.set({ + 'Content-Type': file.mimetype, + 'Content-Disposition': `attachment; filename="${file.filename}"`, + 'Content-Length': file.size, + }); + + return new StreamableFile(file.file_data); + } } diff --git a/apps/backend/src/file-upload/file-upload.service.ts b/apps/backend/src/file-upload/file-upload.service.ts index 8752490a..05d4a8d6 100644 --- a/apps/backend/src/file-upload/file-upload.service.ts +++ b/apps/backend/src/file-upload/file-upload.service.ts @@ -140,4 +140,22 @@ export class FileUploadService { throw new BadRequestException('Failed to retrieve user files'); } } + + /** + * Get a specific file by ID for download + * @param fileId - The ID of the file + * @returns File upload record with file data + */ + async getFileById(fileId: number): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + relations: ['application', 'application.user'], + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + return file; + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 26b94489..7199af5d 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -318,13 +318,27 @@ export class ApiClient { } public async getFiles(userId: number, accessToken: string): Promise { - return this.get(`/api/file-upload/user/${userId}?includeFileData=true`, { + return this.get(`/api/file-upload/user/${userId}?includeFileData=false`, { headers: { Authorization: `Bearer ${accessToken}`, }, }) as Promise; } + public async downloadFile( + accessToken: string, + fileId: number, + ): Promise { + return this.axiosInstance + .get(`/api/file-upload/download/${fileId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + responseType: 'blob', + }) + .then((response) => response.data); + } + private async get( path: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx b/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx index d29cff66..7463da15 100644 --- a/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx +++ b/apps/frontend/src/features/applications/components/ApplicationTables/individualApplication.tsx @@ -14,7 +14,11 @@ import { } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { useState, useEffect } from 'react'; -import { Application, Decision } from '@sharedTypes/types/application.types'; +import { + Application, + Decision, + Position, +} from '@sharedTypes/types/application.types'; import { User } from '@sharedTypes/types/user.types'; import { useNavigate } from 'react-router-dom'; import { @@ -29,6 +33,8 @@ import { AssignedRecruiters } from './AssignedRecruiters'; import { LOGO_PATHS } from '@constants/recruitment'; import { useUserData } from '@shared/hooks/useUserData'; import CodeAmbientBackground from '../../components/CodeAmbientBackground'; +import FileWidget from '../FileWidget'; +import { FilePurpose } from '@sharedTypes/types/file-upload.types'; type IndividualApplicationDetailsProps = { selectedApplication: Application; @@ -55,6 +61,8 @@ const IndividualApplicationDetails = ({ const [reviewComment, setReviewComment] = useState(''); const [decision, setDecision] = useState(null); const [reviewerNames, setReviewerNames] = useState({}); + const [userFiles, setUserFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(true); const navigate = useNavigate(); @@ -147,6 +155,32 @@ const IndividualApplicationDetails = ({ } }, [selectedApplication.reviews, accessToken]); + // Fetch user files + const fetchUserFiles = async () => { + try { + setFilesLoading(true); + const response = await apiClient.getFiles(selectedUser.id, accessToken); + setUserFiles(response.files || []); + } catch (error) { + console.error('Error fetching user files:', error); + setUserFiles([]); + } finally { + setFilesLoading(false); + } + }; + + useEffect(() => { + fetchUserFiles(); + }, [selectedUser.id, accessToken]); + + // Helper to find file by purpose + const getFileByPurpose = (purpose: FilePurpose) => { + return userFiles.find((file) => file.purpose === purpose) || null; + }; + + // Check if applicant is PM + const isPM = selectedApplication.position === Position.PM; + return ( + + {/* File Upload Widgets */} + + {/* Resume Widget - Always visible */} + + + + + {/* PM Challenge Widget - Only for PM applicants */} + {isPM && ( + + + + )} + + void; +} + +const ACCENT = '#9B6CFF'; + +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +}; + +const FileWidget: React.FC = ({ + filePurpose, + fileData, + applicationId, + accessToken, + onFileUploaded, +}) => { + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [downloading, setDownloading] = useState(false); + const [toastOpen, setToastOpen] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const [toastSeverity, setToastSeverity] = useState< + 'success' | 'error' | 'info' + >('info'); + + const displayName = + filePurpose === FilePurpose.RESUME ? 'Resume' : 'PM Challenge'; + + const handleToastClose = () => { + setToastOpen(false); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setSelectedFile(event.target.files[0]); + setToastMessage(`Selected: ${event.target.files[0].name}`); + setToastSeverity('info'); + setToastOpen(true); + } + }; + + const handleUpload = async () => { + if (!selectedFile) return; + try { + setUploading(true); + + await apiClient.uploadFile( + accessToken, + applicationId, + selectedFile, + filePurpose, + ); + + setToastMessage(`${displayName} uploaded successfully!`); + setToastSeverity('success'); + setSelectedFile(null); + setToastOpen(true); + + // Trigger refresh callback + if (onFileUploaded) { + onFileUploaded(); + } + } catch (error: any) { + console.error('Upload failed:', error); + setToastSeverity('error'); + setToastMessage('Upload failed. Please try again.'); + setToastOpen(true); + } finally { + setUploading(false); + } + }; + + const handleDownload = async () => { + if (!fileData) return; + try { + setDownloading(true); + + const blob = await apiClient.downloadFile(accessToken, fileData.id); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileData.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + setToastMessage('File downloaded successfully!'); + setToastSeverity('success'); + setToastOpen(true); + } catch (error: any) { + console.error('Download failed:', error); + setToastSeverity('error'); + setToastMessage('Download failed. Please try again.'); + setToastOpen(true); + } finally { + setDownloading(false); + } + }; + + // Empty State - No file uploaded + if (!fileData) { + return ( + + + + + + {displayName} + + + + + + + No file uploaded yet + + + + + + + {selectedFile && ( + + + Selected: {selectedFile.name} + + + Size: {formatFileSize(selectedFile.size)} + + + + )} + + + + + {toastMessage} + + + + ); + } + + // Filled State - File uploaded + return ( + + + + + + {displayName} + + + + + + + + + {fileData.filename} + + + {formatFileSize(fileData.size)} • {fileData.mimetype} + + + + + + + + {/* Option to replace file */} + + + + + {selectedFile && ( + + + New file: {selectedFile.name} + + + + )} + + + + + + {toastMessage} + + + + ); +}; + +export default FileWidget; From 509b2202e317c5cf39d2ecc4268f9ba3bdf49817 Mon Sep 17 00:00:00 2001 From: Tarun-Nagesh Date: Sat, 22 Nov 2025 20:03:55 -0500 Subject: [PATCH 2/3] Added role clarifiers to applications --- .../applications/applications.controller.ts | 3 ++- .../src/applications/applications.service.ts | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts index 236d1479..d0d0edc0 100644 --- a/apps/backend/src/applications/applications.controller.ts +++ b/apps/backend/src/applications/applications.controller.ts @@ -41,12 +41,13 @@ export class ApplicationsController { @Body('application') application: Response[], @Body('signature') signature: string, @Body('email') email: string, + @Body('role') role: string, ): Promise { const user = await this.applicationsService.verifySignature( email, signature, ); - return await this.applicationsService.submitApp(application, user); + return await this.applicationsService.submitApp(application, user, role); } @Post('/decision/:appId') diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 73e102b6..c1e97dd8 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -51,7 +51,11 @@ export class ApplicationsService { * @throws { BadRequestException } if the user does not exist in our database (i.e., they have not signed up). * @returns { User } the updated user */ - async submitApp(application: Response[], user: User): Promise { + async submitApp( + application: Response[], + user: User, + role?: string, + ): Promise { const { applications: existingApplications } = user; const { year, semester } = getCurrentCycle(); @@ -67,12 +71,25 @@ export class ApplicationsService { ); } + // Determine position from provided role (if any). Default to DEVELOPER. + let positionEnum = Position.DEVELOPER; + if (role) { + const r = (role || '').toString().toUpperCase(); + if (r === 'PM' || r === 'PRODUCT_MANAGER' || r === 'PRODUCT MANAGER') { + positionEnum = Position.PM; + } else if (r === 'DESIGNER') { + positionEnum = Position.DESIGNER; + } else if (r === 'DEVELOPER') { + positionEnum = Position.DEVELOPER; + } + } + const newApplication: Application = this.applicationsRepository.create({ user, createdAt: new Date(), year, semester, - position: Position.DEVELOPER, // TODO: Change this to be dynamic + position: positionEnum, stage: ApplicationStage.APP_RECEIVED, stageProgress: StageProgress.PENDING, response: application, From 79cb0af9a4a9472aedb1231ff8151301e00890e4 Mon Sep 17 00:00:00 2001 From: Tarun-Nagesh Date: Sun, 30 Nov 2025 23:55:04 -0500 Subject: [PATCH 3/3] fixed PM Challenge workflow mishap and overlapping components issue --- .../src/applications/applications.service.ts | 2 ++ .../applicant/components/ApplicantView/user.tsx | 15 +++++++++------ .../applicant/components/FileUploadBox.tsx | 2 +- .../ApplicationTables/individualApplication.tsx | 5 +++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index c1e97dd8..e58ed53c 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -73,6 +73,7 @@ export class ApplicationsService { // Determine position from provided role (if any). Default to DEVELOPER. let positionEnum = Position.DEVELOPER; + this.logger.debug(`submitApp called with role='${role}' for user ${user.email}`); if (role) { const r = (role || '').toString().toUpperCase(); if (r === 'PM' || r === 'PRODUCT_MANAGER' || r === 'PRODUCT MANAGER') { @@ -83,6 +84,7 @@ export class ApplicationsService { positionEnum = Position.DEVELOPER; } } + this.logger.debug(`Mapped role '${role}' -> position '${positionEnum}'`); const newApplication: Application = this.applicationsRepository.create({ user, diff --git a/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx b/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx index 11e17abb..6391d5cb 100644 --- a/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx +++ b/apps/frontend/src/features/applicant/components/ApplicantView/user.tsx @@ -18,6 +18,7 @@ import FileUploadBox from '../FileUploadBox'; import { Application, ApplicationStage, + Position, } from '@sharedTypes/types/application.types'; import { FilePurpose } from '@sharedTypes/types/file-upload.types'; @@ -42,6 +43,9 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => { } }, [selectedApplication]); + // Check if applicant position is PM + const isPM = selectedApplication?.position === Position.PM; + return ( { padding: 2, borderRadius: 2, boxShadow: 2, - width: '70%', - maxWidth: 500, + width: { xs: '95%', md: '70%' }, + maxWidth: 900, position: 'relative', zIndex: 1, }} @@ -113,7 +117,7 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => { borderRadius: 2, boxShadow: 2, textAlign: 'center', - width: '82%', + width: '100%', mb: 3, alignSelf: 'center', }} @@ -123,8 +127,7 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => { {selectedApplication.stage} - {!isLoading && - selectedApplication && + {!isLoading && selectedApplication && isPM && String(selectedApplication.stage) === ApplicationStage.PM_CHALLENGE && ( { backgroundColor: '#1e1e1e', borderRadius: 2, boxShadow: 2, - width: '80%', + width: '100%', alignSelf: 'center', mt: 1, }} diff --git a/apps/frontend/src/features/applicant/components/FileUploadBox.tsx b/apps/frontend/src/features/applicant/components/FileUploadBox.tsx index 095f2ff3..0ed54d72 100644 --- a/apps/frontend/src/features/applicant/components/FileUploadBox.tsx +++ b/apps/frontend/src/features/applicant/components/FileUploadBox.tsx @@ -92,7 +92,7 @@ const FileUploadBox: React.FC = ({ p: 3, mt: 4, textAlign: 'center', - width: 717, + width: '100%', }} > - +