Skip to content

Commit

Permalink
Backend admin application reviewing (#80)
Browse files Browse the repository at this point in the history
* Application decision get and post

* Application decision field migration

* Version bump
  • Loading branch information
echang594 authored Feb 4, 2025
1 parent 97d8d16 commit 7e3d86d
Show file tree
Hide file tree
Showing 11 changed files with 214 additions and 8 deletions.
47 changes: 44 additions & 3 deletions server/api/controllers/AdminController.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import {
Body,
ForbiddenError,
Get,
JsonController,
Params,
Post,
UseBefore,
} from 'routing-controllers';
import { Service } from 'typedi';
import { AuthenticatedUser } from '../decorators/AuthenticatedUser';
import { UserModel } from '../../models/UserModel';
import {
GetApplicationDecisionResponse,
GetFormResponse,
GetFormsResponse,
UpdateApplicationDecisionResponse,
} from '../../types/ApiResponses';
import { UpdateApplicationDecisionRequest } from '../validators/AdminControllerRequests';
import { UserAuthentication } from '../middleware/UserAuthentication';
import { UserService } from '../../services/UserService';
import { ResponseService } from '../../services/ResponseService';
import { IdParam } from '../validators/GenericRequests';
import PermissionsService from '../../services/PermissionsService';

@JsonController('/admin')
@Service()
export class AdminController {
private userService: UserService;

private responseService: ResponseService;

constructor(responseService: ResponseService) {
constructor(userService: UserService, responseService: ResponseService) {
this.userService = userService;
this.responseService = responseService;
}

Expand All @@ -32,7 +41,8 @@ export class AdminController {
@AuthenticatedUser() user: UserModel,
@Params() params: IdParam,
): Promise<GetFormResponse> {
if (!PermissionsService.canViewAllApplications(user)) throw new ForbiddenError();
if (!PermissionsService.canViewAllApplications(user))
throw new ForbiddenError();

const response = await this.responseService.getApplicationById(params.id);
return { error: null, response: response };
Expand All @@ -43,9 +53,40 @@ export class AdminController {
async getApplications(
@AuthenticatedUser() user: UserModel,
): Promise<GetFormsResponse> {
if (!PermissionsService.canViewAllApplications(user)) throw new ForbiddenError();
if (!PermissionsService.canViewAllApplications(user))
throw new ForbiddenError();

const responses = await this.responseService.getAllApplications();
return { error: null, responses: responses };
}

@UseBefore(UserAuthentication)
@Get('/application/:id/decision')
async getApplicationDecision(
@AuthenticatedUser() currentUser: UserModel,
@Params() params: IdParam,
): Promise<GetApplicationDecisionResponse> {
if (!PermissionsService.canViewApplicationDecisions(currentUser))
throw new ForbiddenError();

const user = await this.userService.findById(params.id);
return { error: null, user: user.getHiddenProfile() };
}

@UseBefore(UserAuthentication)
@Post('/application/:id/decision')
async updateApplicationDecision(
@Body() updateApplicationDecisionRequest: UpdateApplicationDecisionRequest,
@AuthenticatedUser() currentUser: UserModel,
@Params() param: IdParam,
): Promise<UpdateApplicationDecisionResponse> {
if (!PermissionsService.canEditApplicationDecisions(currentUser))
throw new ForbiddenError();

const user = await this.userService.updateApplicationDecision(
param.id,
updateApplicationDecisionRequest.applicationDecision,
);
return { error: null, user: user.getHiddenProfile() };
}
}
26 changes: 25 additions & 1 deletion server/api/decorators/Validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
registerDecorator,
ValidatorConstraint,
} from 'class-validator';
import { ApplicationDecision } from '../../types/Enums';

function templatedValidationDecorator(
validator: ValidatorConstraintInterface | Function,
Expand Down Expand Up @@ -37,7 +38,8 @@ export function IsEduEmail(validationOptions?: ValidationOptions) {
@ValidatorConstraint()
class LinkedinValidator implements ValidatorConstraintInterface {
validate(url: string): boolean {
const regex = /^https?:\/\/(www\.)?linkedin\.com\/(in|pub)\/[a-zA-Z0-9-]+\/?$/;
const regex =
/^https?:\/\/(www\.)?linkedin\.com\/(in|pub)\/[a-zA-Z0-9-]+\/?$/;
return regex.test(url);
}

Expand All @@ -49,3 +51,25 @@ class LinkedinValidator implements ValidatorConstraintInterface {
export function IsLinkedinURL(validationOptions?: ValidationOptions) {
return templatedValidationDecorator(LinkedinValidator, validationOptions);
}

@ValidatorConstraint()
class ApplicationDecisionValidator implements ValidatorConstraintInterface {
validate(applicationDecision: ApplicationDecision): boolean {
return Object.values(ApplicationDecision).includes(applicationDecision);
}

defaultMessage(): string {
return `Application decision must be one of ${Object.values(
ApplicationDecision,
)}`;
}
}

export function IsValidApplicationDecision(
validationOptions?: ValidationOptions,
) {
return templatedValidationDecorator(
ApplicationDecisionValidator,
validationOptions,
);
}
10 changes: 10 additions & 0 deletions server/api/validators/AdminControllerRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { IsDefined } from 'class-validator';
import { UpdateApplicationDecisionRequest as IUpdateApplicationDecisionRequest } from '../../types/ApiRequests';
import { ApplicationDecision } from '../../types/Enums';
import { IsValidApplicationDecision } from '../decorators/Validators';

export class UpdateApplicationDecisionRequest implements IUpdateApplicationDecisionRequest {
@IsDefined()
@IsValidApplicationDecision()
applicationDecision: ApplicationDecision;
}
56 changes: 56 additions & 0 deletions server/migrations/003-add-application-decision-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* eslint-disable max-len */
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddApplicationDecisionField1738543800782 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"public\".\"User_applicationdecision_enum\" AS ENUM('NO_DECISION', 'ACCEPT', 'REJECT', 'WAITLIST')",
);
await queryRunner.query(
'ALTER TABLE "User" ADD "applicationDecision" "public"."User_applicationdecision_enum" NOT NULL DEFAULT \'NO_DECISION\'',
);
await queryRunner.query(
'ALTER TYPE "public"."User_applicationstatus_enum" RENAME TO "User_applicationstatus_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"public\".\"User_applicationstatus_enum\" AS ENUM('NOT_SUBMITTED', 'SUBMITTED', 'WITHDRAWN', 'ACCEPTED', 'REJECTED', 'WAITLISTED', 'CONFIRMED')",
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" TYPE "public"."User_applicationstatus_enum" USING "applicationStatus"::"text"::"public"."User_applicationstatus_enum"',
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" SET DEFAULT \'NOT_SUBMITTED\'',
);
await queryRunner.query(
'DROP TYPE "public"."User_applicationstatus_enum_old"',
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"public\".\"User_applicationstatus_enum_old\" AS ENUM('NOT_SUBMITTED', 'SUBMITTED', 'WITHDRAWN', 'ACCEPTED', 'REJECTED', 'CONFIRMED')",
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" TYPE "public"."User_applicationstatus_enum_old" USING "applicationStatus"::"text"::"public"."User_applicationstatus_enum_old"',
);
await queryRunner.query(
'ALTER TABLE "User" ALTER COLUMN "applicationStatus" SET DEFAULT \'NOT_SUBMITTED\'',
);
await queryRunner.query('DROP TYPE "public"."User_applicationstatus_enum"');
await queryRunner.query(
'ALTER TYPE "public"."User_applicationstatus_enum_old" RENAME TO "User_applicationstatus_enum"',
);
await queryRunner.query(
'ALTER TABLE "User" DROP COLUMN "applicationDecision"',
);
await queryRunner.query(
'DROP TYPE "public"."User_applicationdecision_enum"',
);
}
}
25 changes: 23 additions & 2 deletions server/models/UserModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { ResponseModel } from './ResponseModel';
import { ApplicationStatus, UserAccessType } from '../types/Enums';
import { PublicProfile, PrivateProfile } from '../types/ApiResponses';
import {
ApplicationDecision,
ApplicationStatus,
UserAccessType,
} from '../types/Enums';
import {
PublicProfile,
PrivateProfile,
HiddenProfile,
} from '../types/ApiResponses';

@Entity('User')
export class UserModel {
Expand Down Expand Up @@ -36,6 +44,12 @@ export class UserModel {
})
applicationStatus: ApplicationStatus;

@Column('enum', {
enum: ApplicationDecision,
default: ApplicationDecision.NO_DECISION,
})
applicationDecision: ApplicationDecision;

@CreateDateColumn()
createdAt: Date;

Expand Down Expand Up @@ -79,4 +93,11 @@ export class UserModel {
if (this.responses) privateProfile.responses = this.responses;
return privateProfile;
}

public getHiddenProfile(): HiddenProfile {
return {
...this.getPrivateProfile(),
applicationDecision: this.applicationDecision,
};
}
}
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hackathon-portal",
"version": "1.0.0",
"version": "1.1.0",
"description": "REST API for ACM at UCSD's hackathon portal.",
"main": "index.js",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions server/services/PermissionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ export default class PermissionsService {
public static canViewAllApplications(user: UserModel): boolean {
return user.isAdmin();
}

public static canViewApplicationDecisions(user: UserModel): boolean {
return user.isAdmin();
}

public static canEditApplicationDecisions(user: UserModel): boolean {
return user.isAdmin();
}
}
15 changes: 15 additions & 0 deletions server/services/UserService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { UpdateUser } from '../api/validators/UserControllerRequests';
import { auth, adminAuth } from '../FirebaseAuth';
import { UserAndToken } from '../types/ApiResponses';
import { ApplicationDecision } from '../types/Enums';

@Service()
export class UserService {
Expand Down Expand Up @@ -120,6 +121,20 @@ export class UserService {
);
}

public async updateApplicationDecision(
userId: string,
applicationDecision: ApplicationDecision,
): Promise<UserModel> {
return this.transactionsManager.readWrite(async (entityManager) => {
const userRepository = Repositories.user(entityManager);
const user = await userRepository.findById(userId);
if (!user) throw new NotFoundError('User not found');
user.applicationDecision = applicationDecision;
const updatedUser = userRepository.save(user);
return updatedUser;
});
}

public async login(email: string, password: string): Promise<UserAndToken> {
try {
const userCredential = await signInWithEmailAndPassword(
Expand Down
6 changes: 6 additions & 0 deletions server/types/ApiRequests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { UserModel } from '../models/UserModel';
import { ApplicationDecision } from './Enums';

declare global {
namespace Express {
Expand Down Expand Up @@ -34,3 +35,8 @@ export interface LoginRequest {
email: string;
password: string;
}

// Admin requests
export interface UpdateApplicationDecisionRequest {
applicationDecision: ApplicationDecision;
}
19 changes: 18 additions & 1 deletion server/types/ApiResponses.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { ResponseModel } from '../models/ResponseModel';
import { ApplicationStatus, UserAccessType } from './Enums';
import {
ApplicationStatus,
ApplicationDecision,
UserAccessType,
} from './Enums';

// User responses
export interface PublicProfile {
Expand All @@ -17,6 +21,10 @@ export interface PrivateProfile extends PublicProfile {
responses?: ResponseModel;
}

export interface HiddenProfile extends PrivateProfile {
applicationDecision: ApplicationDecision;
}

export interface CustomErrorBody {
name: string;
message: string;
Expand Down Expand Up @@ -71,3 +79,12 @@ export interface SubmitApplicationResponse extends ApiResponse {
}

export interface DeleteApplicationResponse extends ApiResponse {}

// Admin responses
export interface GetApplicationDecisionResponse extends ApiResponse {
user: HiddenProfile;
}

export interface UpdateApplicationDecisionResponse extends ApiResponse {
user: HiddenProfile;
}
8 changes: 8 additions & 0 deletions server/types/Enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ export enum ApplicationStatus {
WITHDRAWN = 'WITHDRAWN',
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
WAITLISTED = 'WAITLISTED',
CONFIRMED = 'CONFIRMED',
}

export enum ApplicationDecision {
NO_DECISION = 'NO_DECISION',
ACCEPT = 'ACCEPT',
REJECT = 'REJECT',
WAITLIST = 'WAITLIST',
}

export enum FormType {
APPLICATION = 'APPLICATION',
}
Expand Down

0 comments on commit 7e3d86d

Please sign in to comment.