Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Submit Application Endpoint #48

Merged
merged 10 commits into from
Feb 18, 2024
2 changes: 1 addition & 1 deletion apps/backend/src/applications/application.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class Application {
@Column('varchar', { default: ApplicationStep.SUBMITTED, nullable: false })
step: ApplicationStep;

@Column('varchar', { array: true, default: {} })
@Column('jsonb')
@IsArray()
@IsObject({ each: true })
response: Response[];
Expand Down
21 changes: 19 additions & 2 deletions apps/backend/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,40 @@ import {
Request,
UseInterceptors,
UseGuards,
Post,
Body,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Response } from './types';
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 { ApplicationsService } from './applications.service';
import { UserStatus } from '../users/types';
import { Application } from './application.entity';

@Controller('apps')
@UseInterceptors(CurrentUserInterceptor)
@UseGuards(AuthGuard('jwt'))
export class ApplicationsController {
constructor(private readonly applicationsService: ApplicationsService) {}

@Post()
async submitApplication(
@Body('application') application: Response[],
@Body('signature') signature: string,
@Body('email') email: string,
): Promise<Application> {
const user = await this.applicationsService.verifySignature(
email,
signature,
);
return await this.applicationsService.submitApp(application, user);
}

@Get('/:userId')
@UseGuards(AuthGuard('jwt'))
async getApplication(
@Param('userId', ParseIntPipe) userId: number,
// TODO make req.user.applications unaccessible
Expand Down
79 changes: 77 additions & 2 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequestException,
UnauthorizedException,
Injectable,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { UsersService } from '../users/users.service';
import { Application } from './application.entity';
import { getAppForCurrentCycle } from './utils';
import { getAppForCurrentCycle, getCurrentCycle } from './utils';
import { Response } from './types';
import * as crypto from 'crypto';
import { User } from '../users/user.entity';
import { Position, ApplicationStage, ApplicationStep } from './types';

@Injectable()
export class ApplicationsService {
Expand All @@ -13,6 +21,73 @@ export class ApplicationsService {
private readonly usersService: UsersService,
) {}

/**
* Submits the application for the given user. Stores the new application in the
* Application table and updates the user's applications field.
*
* @param application holds the user's ID as well as their application responses
* @param user the user who is submitting the application
* @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> {
const { applications: existingApplications } = user;
const { year, semester } = getCurrentCycle();

// TODO Maybe allow for more applications?
if (getAppForCurrentCycle(existingApplications)) {
throw new UnauthorizedException(
`Applicant ${user.id} has already submitted an application for the current cycle`,
);
}

const newApplication: Application = this.applicationsRepository.create({
user,
createdAt: new Date(),
year,
semester,
position: Position.DEVELOPER, // TODO: Change this to be dynamic
stage: ApplicationStage.RESUME,
step: ApplicationStep.SUBMITTED,
response: application,
reviews: [],
});

return await this.applicationsRepository.save(newApplication);
}

/**
* Verifies that this endpoint is being called from our Google Forms.
* Checks that the email was hashed with the correct private key.
*
* @param email the email used for submission on Google Forms
* @param signature the signature corresponding to the hashed email
* @throws { UnauthorizedException } if the signature does not match the expected signature or the calling user
* has not created an account with Code4Community
* @returns { User } the one who submitted the form
*/
async verifySignature(email: string, signature: string): Promise<User> {
const SECRET = process.env.NX_GOOGLE_FORM_SECRET_KEY;
const expectedSignature = crypto
.createHmac('sha256', SECRET)
.update(email)
.digest('base64');

if (signature === expectedSignature) {
const users = await this.usersService.findByEmail(email);
const user = users[0];

// occurs if someone doesn't sign up to our portal before submitting form
// throws exception if email does not exist
if (!user) {
throw new UnauthorizedException();
}
return user;
}
// If the caller of this endpoint submits from anywhere other than our google forms
throw new UnauthorizedException();
}

async findAll(userId: number): Promise<Application[]> {
const apps = await this.applicationsRepository.find({
where: { user: { id: userId } },
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/applications/dto/cycle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Semester } from '../types';

export class Cycle {
constructor(private year: number, private semester: Semester) {}
constructor(public year: number, public semester: Semester) {}

public isCurrentCycle(cycle: Cycle): boolean {
return this.year === cycle.year && this.semester === cycle.semester;
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/users/dto/update-user.request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Application } from '../../applications/application.entity';
import { UserStatus, Role, Team } from '../types';
import {
IsEmail,
Expand All @@ -7,6 +8,7 @@ import {
ArrayMinSize,
ArrayUnique,
IsUrl,
IsObject,
} from 'class-validator';

export class UpdateUserRequestDTO {
Expand Down Expand Up @@ -48,4 +50,9 @@ export class UpdateUserRequestDTO {
@ArrayUnique()
@IsEnum(Role, { each: true })
role?: Role[];

@IsOptional()
@IsArray()
@IsObject({ each: true })
applications?: Application[];
}
6 changes: 5 additions & 1 deletion apps/backend/src/users/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
IsPositive,
IsString,
IsUrl,
IsObject,
} from 'class-validator';
import { Entity, Column, OneToMany } from 'typeorm';
import { Application } from '../applications/application.entity';
import { Role, Team, UserStatus } from './types';
import { GetUserResponseDto } from './dto/get-user.response.dto';

@Entity()
export class User {
Expand Down Expand Up @@ -65,6 +65,10 @@ export class User {
@IsEnum(Role, { each: true })
role: Role[] | null;

// TODO remove { nullable: true }
@Column('jsonb', { nullable: true, default: [] })
@IsArray()
@IsObject({ each: true })
@OneToMany(() => Application, (application) => application.user)
applications: Application[];
}
5 changes: 4 additions & 1 deletion apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export class UsersService {
}

findByEmail(email: string): Promise<User[]> {
return this.usersRepository.find({ where: { email } });
return this.usersRepository.find({
where: { email },
relations: ['applications'],
});
}

async updateUser(
Expand Down
Loading