Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/backend/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ export class ApplicationsController {
@Body('application') application: Response[],
@Body('signature') signature: string,
@Body('email') email: string,
@Body('role') role: string,
): Promise<Application> {
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')
Expand Down
34 changes: 29 additions & 5 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
Position,
ApplicationStage,
StageProgress,
Semester,

Check warning on line 24 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

'Semester' is defined but never used
} from '@shared/types/application.types';
import * as crypto from 'crypto';
import { User } from '../users/user.entity';
import { forEach } from 'lodash';

Check warning on line 28 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

'forEach' is defined but never used
import { Review } from '../reviews/review.entity';
import { UserStatus } from '@shared/types/user.types';
import { stagesMap } from './applications.constants';
Expand All @@ -51,7 +51,11 @@
* @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<Application> {
async submitApp(
application: Response[],
user: User,
role?: string,
): Promise<Application> {
const { applications: existingApplications } = user;
const { year, semester } = getCurrentCycle();

Expand All @@ -67,12 +71,27 @@
);
}

// 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') {
positionEnum = Position.PM;
} else if (r === 'DESIGNER') {
positionEnum = Position.DESIGNER;
} else if (r === 'DEVELOPER') {
positionEnum = Position.DEVELOPER;
}
}
this.logger.debug(`Mapped role '${role}' -> position '${positionEnum}'`);

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,
Expand Down Expand Up @@ -409,7 +428,7 @@
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);

Expand Down Expand Up @@ -500,7 +519,10 @@
* 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[],

Check warning on line 524 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

Unexpected any. Specify a different type
): StageProgress {
const stage = app.stage;

// Terminal stages are always completed
Expand Down Expand Up @@ -530,7 +552,9 @@
reviewerIdsForStage.has(id),
);

return allAssignedReviewed ? StageProgress.COMPLETED : StageProgress.PENDING;
return allAssignedReviewed
? StageProgress.COMPLETED
: StageProgress.PENDING;
}

/**
Expand Down
20 changes: 20 additions & 0 deletions apps/backend/src/file-upload/file-upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<StreamableFile> {
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);
}
}
18 changes: 18 additions & 0 deletions apps/backend/src/file-upload/file-upload.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileUpload> {
const file = await this.fileRepository.findOne({
where: { id: fileId },
relations: ['application', 'application.user'],
});

if (!file) {
throw new NotFoundException('File not found');
}

return file;
}
}
16 changes: 15 additions & 1 deletion apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,27 @@ export class ApiClient {
}

public async getFiles(userId: number, accessToken: string): Promise<any> {
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<any>;
}

public async downloadFile(
accessToken: string,
fileId: number,
): Promise<Blob> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -42,6 +43,9 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => {
}
}, [selectedApplication]);

// Check if applicant position is PM
const isPM = selectedApplication?.position === Position.PM;

return (
<Box
sx={{
Expand Down Expand Up @@ -90,8 +94,8 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => {
padding: 2,
borderRadius: 2,
boxShadow: 2,
width: '70%',
maxWidth: 500,
width: { xs: '95%', md: '70%' },
maxWidth: 900,
position: 'relative',
zIndex: 1,
}}
Expand All @@ -113,7 +117,7 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => {
borderRadius: 2,
boxShadow: 2,
textAlign: 'center',
width: '82%',
width: '100%',
mb: 3,
alignSelf: 'center',
}}
Expand All @@ -123,8 +127,7 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => {
{selectedApplication.stage}
</Typography>
</Box>
{!isLoading &&
selectedApplication &&
{!isLoading && selectedApplication && isPM &&
String(selectedApplication.stage) ===
ApplicationStage.PM_CHALLENGE && (
<FileUploadBox
Expand All @@ -142,7 +145,7 @@ export const ApplicantView = ({ user }: ApplicantViewProps) => {
backgroundColor: '#1e1e1e',
borderRadius: 2,
boxShadow: 2,
width: '80%',
width: '100%',
alignSelf: 'center',
mt: 1,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const FileUploadBox: React.FC<FileUploadBoxProps> = ({
p: 3,
mt: 4,
textAlign: 'center',
width: 717,
width: '100%',
}}
>
<Typography
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -55,6 +61,8 @@ const IndividualApplicationDetails = ({
const [reviewComment, setReviewComment] = useState('');
const [decision, setDecision] = useState<Decision | null>(null);
const [reviewerNames, setReviewerNames] = useState<ReviewerInfo>({});
const [userFiles, setUserFiles] = useState<any[]>([]);
const [filesLoading, setFilesLoading] = useState(true);

const navigate = useNavigate();

Expand Down Expand Up @@ -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 (
<Stack
direction="column"
Expand Down Expand Up @@ -332,6 +366,34 @@ const IndividualApplicationDetails = ({
</Grid>
</Grid>
</Card>

{/* File Upload Widgets */}
<Grid container spacing={2} sx={{ mt: 1, mb: 1 }}>
{/* Resume Widget - Always visible */}
<Grid item xs={12} md={isPM ? 6 : 12}>
<FileWidget
filePurpose={FilePurpose.RESUME}
fileData={getFileByPurpose(FilePurpose.RESUME)}
applicationId={selectedApplication.id}
accessToken={accessToken}
onFileUploaded={fetchUserFiles}
/>
</Grid>

{/* PM Challenge Widget - Only for PM applicants */}
{isPM && (
<Grid item xs={12} md={6}>
<FileWidget
filePurpose={FilePurpose.PM_CHALLENGE}
fileData={getFileByPurpose(FilePurpose.PM_CHALLENGE)}
applicationId={selectedApplication.id}
accessToken={accessToken}
onFileUploaded={fetchUserFiles}
/>
</Grid>
)}
</Grid>

<Grid container spacing={1.5}>
<Grid item xs={12} md={8}>
<Stack
Expand Down Expand Up @@ -373,7 +435,7 @@ const IndividualApplicationDetails = ({
))}
</Stack>
</Grid>
<Grid item xs={12} md={4} sx={{ mt: { md: -25 } }}>
<Grid item xs={12} md={4} sx={{ mt: { md: 0 } }}>
<Stack
direction="column"
sx={{
Expand All @@ -382,8 +444,9 @@ const IndividualApplicationDetails = ({
p: { xs: 2, md: 2.5 },
backgroundColor: 'transparent',
gap: 1.5,
// Use sticky positioning without negative offsets to avoid overlap
position: { md: 'sticky' },
top: { md: -104 },
top: { md: 24 },
}}
>
<Typography
Expand Down
Loading
Loading