diff --git a/server/api/controllers/AdminController.ts b/server/api/controllers/AdminController.ts index a356685..9c230d2 100644 --- a/server/api/controllers/AdminController.ts +++ b/server/api/controllers/AdminController.ts @@ -1,18 +1,24 @@ 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'; @@ -20,9 +26,12 @@ 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; } @@ -32,7 +41,8 @@ export class AdminController { @AuthenticatedUser() user: UserModel, @Params() params: IdParam, ): Promise { - 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 }; @@ -43,9 +53,40 @@ export class AdminController { async getApplications( @AuthenticatedUser() user: UserModel, ): Promise { - 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 { + 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 { + if (!PermissionsService.canEditApplicationDecisions(currentUser)) + throw new ForbiddenError(); + + const user = await this.userService.updateApplicationDecision( + param.id, + updateApplicationDecisionRequest.applicationDecision, + ); + return { error: null, user: user.getHiddenProfile() }; + } } diff --git a/server/api/decorators/Validators.ts b/server/api/decorators/Validators.ts index 42d97f7..f9bc600 100644 --- a/server/api/decorators/Validators.ts +++ b/server/api/decorators/Validators.ts @@ -4,6 +4,7 @@ import { registerDecorator, ValidatorConstraint, } from 'class-validator'; +import { ApplicationDecision } from '../../types/Enums'; function templatedValidationDecorator( validator: ValidatorConstraintInterface | Function, @@ -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); } @@ -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, + ); +} diff --git a/server/api/validators/AdminControllerRequests.ts b/server/api/validators/AdminControllerRequests.ts new file mode 100644 index 0000000..543f543 --- /dev/null +++ b/server/api/validators/AdminControllerRequests.ts @@ -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; +} diff --git a/server/migrations/003-add-application-decision-field.ts b/server/migrations/003-add-application-decision-field.ts new file mode 100644 index 0000000..1a70a67 --- /dev/null +++ b/server/migrations/003-add-application-decision-field.ts @@ -0,0 +1,56 @@ +/* eslint-disable max-len */ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApplicationDecisionField1738543800782 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"', + ); + } +} diff --git a/server/models/UserModel.ts b/server/models/UserModel.ts index 2eafa18..3ee0586 100644 --- a/server/models/UserModel.ts +++ b/server/models/UserModel.ts @@ -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 { @@ -36,6 +44,12 @@ export class UserModel { }) applicationStatus: ApplicationStatus; + @Column('enum', { + enum: ApplicationDecision, + default: ApplicationDecision.NO_DECISION, + }) + applicationDecision: ApplicationDecision; + @CreateDateColumn() createdAt: Date; @@ -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, + }; + } } diff --git a/server/package.json b/server/package.json index c98697f..0dc1293 100644 --- a/server/package.json +++ b/server/package.json @@ -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": { diff --git a/server/services/PermissionsService.ts b/server/services/PermissionsService.ts index 5eb8299..29a1bdc 100644 --- a/server/services/PermissionsService.ts +++ b/server/services/PermissionsService.ts @@ -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(); + } } diff --git a/server/services/UserService.ts b/server/services/UserService.ts index eed3f8d..d652d6d 100644 --- a/server/services/UserService.ts +++ b/server/services/UserService.ts @@ -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 { @@ -120,6 +121,20 @@ export class UserService { ); } + public async updateApplicationDecision( + userId: string, + applicationDecision: ApplicationDecision, + ): Promise { + 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 { try { const userCredential = await signInWithEmailAndPassword( diff --git a/server/types/ApiRequests.ts b/server/types/ApiRequests.ts index a7bdec7..d8c8e86 100644 --- a/server/types/ApiRequests.ts +++ b/server/types/ApiRequests.ts @@ -1,4 +1,5 @@ import { UserModel } from '../models/UserModel'; +import { ApplicationDecision } from './Enums'; declare global { namespace Express { @@ -34,3 +35,8 @@ export interface LoginRequest { email: string; password: string; } + +// Admin requests +export interface UpdateApplicationDecisionRequest { + applicationDecision: ApplicationDecision; +} diff --git a/server/types/ApiResponses.ts b/server/types/ApiResponses.ts index 4eea333..4768677 100644 --- a/server/types/ApiResponses.ts +++ b/server/types/ApiResponses.ts @@ -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 { @@ -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; @@ -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; +} diff --git a/server/types/Enums.ts b/server/types/Enums.ts index c72cdf3..3c6cb44 100644 --- a/server/types/Enums.ts +++ b/server/types/Enums.ts @@ -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', }