diff --git a/apps/backend/src/applications/application.entity.ts b/apps/backend/src/applications/application.entity.ts index 5bc315cb..b3fee940 100644 --- a/apps/backend/src/applications/application.entity.ts +++ b/apps/backend/src/applications/application.entity.ts @@ -58,12 +58,23 @@ export class Application { @IsObject({ each: true }) response: Response[]; + @Column('jsonb', { nullable: true, default: [] }) + @IsArray() + @IsObject({ each: true }) + @OneToMany(() => User, (user) => user.firstName + user.lastName) + recruiters: User[]; + @Column('varchar', { array: true, default: {} }) @IsArray() @IsObject({ each: true }) @OneToMany(() => Review, (review) => review.application) reviews: Review[]; + @Column({ nullable: false, default: 0 }) + @IsPositive() + @Min(0) + numApps: number; + toGetAllApplicationResponseDTO( meanRatingAllReviews, meanRatingResume, @@ -80,6 +91,7 @@ export class Application { step: applicationStep, position: this.position, createdAt: this.createdAt, + recruiters: this.recruiters, meanRatingAllReviews, meanRatingResume, meanRatingChallenge, @@ -101,6 +113,7 @@ export class Application { stage: this.stage, step: applicationStep, response: this.response, + recruiters: this.recruiters, reviews: this.reviews, numApps, }; diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts index b8fb322a..fd499df3 100644 --- a/apps/backend/src/applications/applications.controller.ts +++ b/apps/backend/src/applications/applications.controller.ts @@ -7,6 +7,7 @@ import { UseInterceptors, UseGuards, Post, + Patch, Body, BadRequestException, NotFoundException, @@ -17,11 +18,12 @@ import { ApplicationsService } from './applications.service'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthGuard } from '@nestjs/passport'; import { GetApplicationResponseDTO } from './dto/get-application.response.dto'; -import { getAppForCurrentCycle } from './utils'; +import { getAppForCurrentCycle, toGetApplicationResponseDTO } from './utils'; import { UserStatus } from '../users/types'; import { Application } from './application.entity'; import { GetAllApplicationResponseDTO } from './dto/get-all-application.response.dto'; import { ApplicationStep } from './types'; +import { UpdateApplicationRequestDTO } from './dto/update-application.request.dto'; @Controller('apps') @UseInterceptors(CurrentUserInterceptor) @@ -115,4 +117,26 @@ export class ApplicationsController { return app.toGetApplicationResponseDTO(apps.length, applicationStep); } + + // TODO: Update DTOs + @Patch('/:applicantId') + async updateApplication( + @Body() updateApplicationDTO: UpdateApplicationRequestDTO, + @Param('applicantId', ParseIntPipe) applicantId: number, + @Request() req, + ): Promise { + if (req.user.status !== UserStatus.ADMIN) { + throw new UnauthorizedException( + 'Only admins can assign recruiters to applicants', + ); + } + + const newApplicant = await this.applicationsService.updateApplication( + req.application, + applicantId, + updateApplicationDTO, + ); + + return toGetApplicationResponseDTO(newApplicant); + } } diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 596d62f2..029637d9 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, + NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -19,6 +20,7 @@ import { User } from '../users/user.entity'; import { Position, ApplicationStage, ApplicationStep } from './types'; import { GetAllApplicationResponseDTO } from './dto/get-all-application.response.dto'; import { stagesMap } from './applications.constants'; +import { UpdateApplicationRequestDTO } from './dto/update-application.request.dto'; @Injectable() export class ApplicationsService { @@ -237,4 +239,40 @@ export class ApplicationsService { return currentApp; } + + async findOne( + currentApplication: Application, + applicationId: number, + ): Promise { + const application = await this.applicationsRepository.findOne({ + where: { id: applicationId }, + }); + + if (!application) { + throw new NotFoundException( + `Application with ID ${applicationId} not found`, + ); + } + + return application; + } + + async updateApplication( + currentApplication: Application, + applicationId: number, + updateApplicationDTO: UpdateApplicationRequestDTO, + ): Promise { + await this.findOne(currentApplication, applicationId); + + try { + await this.applicationsRepository.update( + { id: applicationId }, + updateApplicationDTO, + ); + } catch (error) { + throw new BadRequestException('Cannot update application'); + } + + return await this.findOne(currentApplication, applicationId); + } } diff --git a/apps/backend/src/applications/dto/get-all-application.response.dto.ts b/apps/backend/src/applications/dto/get-all-application.response.dto.ts index ee6fd71c..a01ba496 100644 --- a/apps/backend/src/applications/dto/get-all-application.response.dto.ts +++ b/apps/backend/src/applications/dto/get-all-application.response.dto.ts @@ -1,5 +1,6 @@ -import { IsDate, IsEnum, IsPositive, IsString } from 'class-validator'; +import { IsArray, IsDate, IsEnum, IsPositive, IsString } from 'class-validator'; import { ApplicationStage, ApplicationStep, Position } from '../types'; +import { User } from '../../users/user.entity'; export class GetAllApplicationResponseDTO { @IsPositive() @@ -23,6 +24,9 @@ export class GetAllApplicationResponseDTO { @IsDate() createdAt: Date; + @IsArray() + recruiters: User[]; + @IsPositive() meanRatingAllReviews: number; diff --git a/apps/backend/src/applications/dto/get-application.response.dto.ts b/apps/backend/src/applications/dto/get-application.response.dto.ts index 7e595453..e35a44fe 100644 --- a/apps/backend/src/applications/dto/get-application.response.dto.ts +++ b/apps/backend/src/applications/dto/get-application.response.dto.ts @@ -1,4 +1,5 @@ import { Review } from '../../reviews/review.entity'; +import { User } from '../../users/user.entity'; import { ApplicationStage, ApplicationStep, @@ -24,6 +25,8 @@ export class GetApplicationResponseDTO { response: Response[]; + recruiters: User[]; + reviews: Review[]; numApps: number; diff --git a/apps/backend/src/applications/dto/update-application.request.dto.ts b/apps/backend/src/applications/dto/update-application.request.dto.ts new file mode 100644 index 00000000..65dc938b --- /dev/null +++ b/apps/backend/src/applications/dto/update-application.request.dto.ts @@ -0,0 +1,48 @@ +import { User } from '../../users/user.entity'; +import { Application } from '../application.entity'; +import { + Semester, + Position, + ApplicationStage, + ApplicationStep, +} from '../types'; +import { Review } from '../../reviews/review.entity'; +import { + IsArray, + IsEnum, + IsObject, + IsOptional, + IsPositive, +} from 'class-validator'; + +export class UpdateApplicationRequestDTO { + @IsOptional() + @IsEnum(Semester) + semester?: Semester; + + @IsOptional() + @IsEnum(Position) + position?: Position; + + @IsOptional() + @IsEnum(ApplicationStage) + stage: ApplicationStage; + + @IsOptional() + @IsEnum(ApplicationStep) + step: ApplicationStep; + + @IsOptional() + @IsArray() + @IsObject({ each: true }) + reviews: Review[]; + + @IsOptional() + @IsArray() + @IsObject({ each: true }) + recruiters: User[]; + + @IsOptional() + @IsPositive() + numApps: number; +} diff --git a/apps/backend/src/applications/utils.ts b/apps/backend/src/applications/utils.ts index b24a353d..351a6412 100644 --- a/apps/backend/src/applications/utils.ts +++ b/apps/backend/src/applications/utils.ts @@ -1,5 +1,6 @@ import { Application } from './application.entity'; import { Cycle } from './dto/cycle'; +import { GetApplicationResponseDTO } from './dto/get-application.response.dto'; import { Semester } from './types'; export const getCurrentSemester = (): Semester => { @@ -34,3 +35,21 @@ export const getAppForCurrentCycle = ( return null; }; + +export const toGetApplicationResponseDTO = ( + application: Application, +): GetApplicationResponseDTO => { + return { + id: application.id, + createdAt: application.createdAt, + year: application.year, + semester: application.semester, + position: application.position, + stage: application.stage, + step: application.step, + response: application.response, + recruiters: application.recruiters, + reviews: application.reviews, + numApps: application.numApps, + }; +}; diff --git a/apps/backend/src/testing/factories/user.factory.ts b/apps/backend/src/testing/factories/user.factory.ts index ea42f4a7..926f841c 100644 --- a/apps/backend/src/testing/factories/user.factory.ts +++ b/apps/backend/src/testing/factories/user.factory.ts @@ -15,6 +15,7 @@ export const defaultUser: User = { team: null, role: null, applications: [], + recruiters: [], }; export const userFactory = (user: Partial = {}): User => diff --git a/apps/backend/src/users/dto/get-user.response.dto.ts b/apps/backend/src/users/dto/get-user.response.dto.ts index 16d77b3e..d39614aa 100644 --- a/apps/backend/src/users/dto/get-user.response.dto.ts +++ b/apps/backend/src/users/dto/get-user.response.dto.ts @@ -1,6 +1,7 @@ import { Entity } from 'typeorm'; import { Role, Team, UserStatus } from '../types'; +// TODO: Update this DTO and toGetUserResponseDto to include 'recruiters' field @Entity() export class GetUserResponseDto { id: number; diff --git a/apps/backend/src/users/dto/update-user.request.dto.ts b/apps/backend/src/users/dto/update-user.request.dto.ts index 1bb0cf39..d00a1627 100644 --- a/apps/backend/src/users/dto/update-user.request.dto.ts +++ b/apps/backend/src/users/dto/update-user.request.dto.ts @@ -1,3 +1,4 @@ +import { User } from '../../users/user.entity'; import { Application } from '../../applications/application.entity'; import { UserStatus, Role, Team } from '../types'; import { @@ -55,4 +56,9 @@ export class UpdateUserRequestDTO { @IsArray() @IsObject({ each: true }) applications?: Application[]; + + @IsOptional() + @IsArray() + @IsObject({ each: true }) + recruiters?: User[]; } diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 702be10b..391384df 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -71,4 +71,10 @@ export class User { @IsObject({ each: true }) @OneToMany(() => Application, (application) => application.user) applications: Application[]; + + @Column('jsonb', { nullable: true, default: [] }) + @IsArray() + @IsObject({ each: true }) + @OneToMany(() => User, (user) => user.firstName + user.lastName) + recruiters: User[]; } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 13215957..29a21e9c 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -3,6 +3,7 @@ import type { Application, ApplicationRow, ApplicationStage, + User, } from '@components/types'; const defaultBaseUrl = @@ -47,6 +48,14 @@ export class ApiClient { })) as Promise; } + public async getAllRecruiters(accessToken: string): Promise { + return (await this.get('/api/users/recruiters', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + })) as Promise; + } + public async getApplication( accessToken: string, userId: number, diff --git a/apps/frontend/src/components/ApplicationTables/columns.ts b/apps/frontend/src/components/ApplicationTables/columns.ts index a2d8f52f..d1f6b96e 100644 --- a/apps/frontend/src/components/ApplicationTables/columns.ts +++ b/apps/frontend/src/components/ApplicationTables/columns.ts @@ -29,6 +29,11 @@ export const applicationColumns = [ headerName: 'Date', width: 150, }, + { + field: 'assignedRecruiters', + headerName: 'Assigned Recruiters', + width: 150, + }, { field: 'meanRatingAllStages', headerName: 'Rating All Stages', diff --git a/apps/frontend/src/components/ApplicationTables/index.tsx b/apps/frontend/src/components/ApplicationTables/index.tsx index ac4c7572..4bae6371 100644 --- a/apps/frontend/src/components/ApplicationTables/index.tsx +++ b/apps/frontend/src/components/ApplicationTables/index.tsx @@ -9,17 +9,25 @@ import { ListItemText, ListItemIcon, Button, + Checkbox, + TextField, + Autocomplete, } from '@mui/material'; +import { CheckBoxOutlineBlank, CheckBox } from '@mui/icons-material'; import { DoneOutline } from '@mui/icons-material'; -import { ApplicationRow, Application, Semester } from '../types'; +import { ApplicationRow, Application, Semester, User } from '../types'; import apiClient from '@api/apiClient'; import { applicationColumns } from './columns'; import { ReviewModal } from './reviewModal'; import useLoginContext from '@components/LoginPage/useLoginContext'; +import { light } from '@mui/material/styles/createPalette'; const TODAY = new Date(); +const checkBoxIconUnchecked = ; +const checkBoxIconChecked = ; + const getCurrentSemester = (): Semester => { const month: number = TODAY.getMonth(); if (month >= 1 && month <= 7) { @@ -43,6 +51,8 @@ export function ApplicationTable() { const [selectedUserRow, setSelectedUserRow] = useState( null, ); + const [allRecruitersList, setAllRecruitersList] = useState>([]); + const [selectedApplication, setSelectedApplication] = useState(null); @@ -52,6 +62,11 @@ export function ApplicationTable() { setOpenReviewModal(true); }; + const fetchRecruiters = async () => { + const data = await apiClient.getAllRecruiters(accessToken); + setAllRecruitersList(data); + }; + const fetchData = async () => { const data = await apiClient.getAllApplications(accessToken); // Each application needs an id for the DataGrid to work @@ -92,6 +107,20 @@ export function ApplicationTable() { } }, [rowSelection, data]); + const handleRecruitersChange = async ( + event: React.SyntheticEvent, + value: User[], + ) => { + event.preventDefault(); + + // TODO: This should call updateApplicant, which needs to be implemented + /* + if (selectedApplication) { + await apiClient.updateApplicant(accessToken, selectedApplication.id, value); + } + */ + }; + return ( @@ -115,6 +144,7 @@ export function ApplicationTable() { onRowSelectionModelChange={(newRowSelectionModel) => { setRowSelection(newRowSelectionModel); getApplication(data[newRowSelectionModel[0] as number].userId); + fetchRecruiters(); }} rowSelectionModel={rowSelection} /> @@ -150,7 +180,36 @@ export function ApplicationTable() { Applications: {selectedApplication.numApps} + Recruiters: + + recruiter.firstName + ' ' + recruiter.lastName + } + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = + props as React.HTMLAttributes & { + key: string; + }; + return ( +
  • + + {option.firstName + ' ' + option.lastName} +
  • + ); + }} + style={{ width: 400, marginTop: 10 }} + renderInput={(params) => ( + + )} + /> Application Responses diff --git a/apps/frontend/src/components/types.ts b/apps/frontend/src/components/types.ts index bac5d09b..8cf7f7b8 100644 --- a/apps/frontend/src/components/types.ts +++ b/apps/frontend/src/components/types.ts @@ -18,6 +18,39 @@ enum Position { DESIGNER = 'DESIGNER', } +enum UserStatus { + MEMBER = 'Member', + RECRUITER = 'Recruiter', + ADMIN = 'Admin', + ALUMNI = 'Alumni', + APPLICANT = 'Applicant', +} + +enum Team { + SFTT = 'Speak for the Trees', + CONSTELLATION = 'Constellation', + JPAL = 'J-PAL', + BREAKTIME = 'Breaktime', + GI = 'Green Infrastructure', + CI = 'Core Infrastructure', + EBOARD = 'E-Board', +} + +enum Role { + DIRECTOR_OF_ENGINEERING = 'Director of Engineering', + DIRECTOR_OF_PRODUCT = 'Director of Product', + DIRECTOR_OF_FINANCE = 'Director of Finance', + DIRECTOR_OF_MARKETING = 'Director of Marketing', + DIRECTOR_OF_RECRUITMENT = 'Director of Recruitment', + DIRECTOR_OF_OPERATIONS = 'Director of Operations', + DIRECTOR_OF_EVENTS = 'Director of Events', + DIRECTOR_OF_DESIGN = 'Director of Design', + PRODUCT_MANAGER = 'Product Manager', + PRODUCT_DESIGNER = 'Product Designer', + TECH_LEAD = 'Technical Lead', + DEVELOPER = 'Developer', +} + type ApplicationRow = { id: number; userId: number; @@ -62,14 +95,34 @@ type Application = { response: Response[]; numApps: number; reviews: Review[]; + assignedRecruiters: User[]; +}; + +type User = { + id: number; + status: UserStatus; + firstName: string; + lastName: string; + email: string; + profilePicture: string | null; + linkedin: string | null; + github: string | null; + team: Team | null; + role: Role[] | null; + applications: Application[]; + recruiters: User[]; }; export { ApplicationRow, Application, + User, ApplicationStage, ApplicationStep, Position, + UserStatus, + Team, + Role, Response, Review, Semester,