Skip to content

Backend admin application reviewing #80

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

Merged
merged 3 commits into from
Feb 4, 2025
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
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
Loading