From 820bf9fd732a13977ea8476e67daf4d26b9d1635 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 19 Oct 2025 00:13:04 -0400 Subject: [PATCH 1/2] Add organizer applications module with CRUD functionality and resume upload --- ...033849_add_organizer_applications_table.ts | 103 +++++++++ src/app.module.ts | 2 + src/entities/organizer-application.entity.ts | 155 +++++++++++++ .../organizer-application.controller.ts | 184 +++++++++++++++ .../organizer-application.module.ts | 12 + .../organizer-application.service.ts | 218 ++++++++++++++++++ .../uploaded-resume.decorator.ts | 14 ++ 7 files changed, 688 insertions(+) create mode 100644 db/migrations/20251019033849_add_organizer_applications_table.ts create mode 100644 src/entities/organizer-application.entity.ts create mode 100644 src/modules/organizer-application/organizer-application.controller.ts create mode 100644 src/modules/organizer-application/organizer-application.module.ts create mode 100644 src/modules/organizer-application/organizer-application.service.ts create mode 100644 src/modules/organizer-application/uploaded-resume.decorator.ts diff --git a/db/migrations/20251019033849_add_organizer_applications_table.ts b/db/migrations/20251019033849_add_organizer_applications_table.ts new file mode 100644 index 00000000..bf78af12 --- /dev/null +++ b/db/migrations/20251019033849_add_organizer_applications_table.ts @@ -0,0 +1,103 @@ +import type { Knex } from "knex"; + + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("organizer_applications", (table) => { + table.increments("id").unsigned().primary().notNullable(); + + // Basic Information + table.string("name").notNullable(); + table.string("email").notNullable(); + + // Year and Major + table + .enum("year_standing", [ + "Freshman", + "Sophomore", + "Junior", + "Senior", + "Other", + ]) + .notNullable(); + table.string("major").notNullable(); + + // Team Preferences + table + .enum("first_choice_team", [ + "Communications", + "Design", + "Education", + "Entertainment", + "Finance", + "Logistics", + "Marketing", + "Sponsorship", + "Technology", + ]) + .notNullable(); + table + .enum("second_choice_team", [ + "Communications", + "Design", + "Education", + "Entertainment", + "Finance", + "Logistics", + "Marketing", + "Sponsorship", + "Technology", + ]) + .notNullable(); + + // Resume (stored in Firebase, this stores the path/URL) + table.string("resume_url").notNullable(); + + // Application Questions + table.text("why_hackpsu", "longtext").notNullable(); + table.text("new_idea", "longtext").notNullable(); + table.text("what_excites_you", "longtext").notNullable(); + + // Application Status for each team preference + table + .enum("first_choice_status", ["pending", "accepted", "rejected"]) + .notNullable() + .defaultTo("pending"); + table + .enum("second_choice_status", ["pending", "accepted", "rejected"]) + .notNullable() + .defaultTo("pending"); + + // Final assigned team (set when either first or second choice accepts) + table + .enum("assigned_team", [ + "Communications", + "Design", + "Education", + "Entertainment", + "Finance", + "Logistics", + "Marketing", + "Sponsorship", + "Technology", + ]) + .nullable(); + + // Timestamps + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + // Indexes for efficient querying + table.index("email"); + table.index("first_choice_status"); + table.index("second_choice_status"); + table.index("first_choice_team"); + table.index("second_choice_team"); + table.index("assigned_team"); + }); +} + + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("organizer_applications"); +} + diff --git a/src/app.module.ts b/src/app.module.ts index 89db533b..5b66de61 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -40,6 +40,7 @@ import { DriveModule } from "modules/drive/drive.module"; import { NotificationSchedulerModule } from "modules/notification-scheduler/notification-scheduler.module"; import { GotifyModule } from "common/gotify/gotify.module"; import gotifyConfig from "common/gotify/gotify.config"; +import { OrganizerApplicationModule } from "modules/organizer-application/organizer-application.module"; @Module({ imports: [ @@ -110,6 +111,7 @@ import gotifyConfig from "common/gotify/gotify.config"; PhotoModule, ReservationModule, DriveModule, + OrganizerApplicationModule, // WebSocket SocketModule, diff --git a/src/entities/organizer-application.entity.ts b/src/entities/organizer-application.entity.ts new file mode 100644 index 00000000..fd9467ee --- /dev/null +++ b/src/entities/organizer-application.entity.ts @@ -0,0 +1,155 @@ +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Column, ID, Table } from "common/objection"; +import { Entity } from "entities/base.entity"; +import { + IsEmail, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; +import { Expose } from "class-transformer"; +import { ControllerMethod } from "common/validation"; + +export enum YearStanding { + FRESHMAN = "Freshman", + SOPHOMORE = "Sophomore", + JUNIOR = "Junior", + SENIOR = "Senior", + OTHER = "Other", +} + +export enum OrganizerTeam { + COMMUNICATIONS = "Communications", + DESIGN = "Design", + EDUCATION = "Education", + ENTERTAINMENT = "Entertainment", + FINANCE = "Finance", + LOGISTICS = "Logistics", + MARKETING = "Marketing", + SPONSORSHIP = "Sponsorship", + TECHNOLOGY = "Technology", +} + +export enum ApplicationStatus { + PENDING = "pending", + ACCEPTED = "accepted", + REJECTED = "rejected", +} + +@Table({ + name: "organizer_applications", + disableByHackathon: true, +}) +export class OrganizerApplication extends Entity { + @ApiProperty() + @Expose({ groups: [ControllerMethod.POST] }) + @IsNumber() + @ID({ type: "number" }) + id: number; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + name: string; + + @ApiProperty() + @IsEmail() + @Column({ type: "string" }) + email: string; + + @ApiProperty({ enum: YearStanding }) + @IsEnum(YearStanding) + @Column({ type: "string" }) + yearStanding: YearStanding; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + major: string; + + @ApiProperty({ enum: OrganizerTeam }) + @IsEnum(OrganizerTeam) + @Column({ type: "string" }) + firstChoiceTeam: OrganizerTeam; + + @ApiProperty({ enum: OrganizerTeam }) + @IsEnum(OrganizerTeam) + @Column({ type: "string" }) + secondChoiceTeam: OrganizerTeam; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + resumeUrl: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + whyHackpsu: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + newIdea: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + whatExcitesYou: string; + + @ApiProperty({ + enum: ApplicationStatus, + default: ApplicationStatus.PENDING, + required: false, + }) + @IsOptional() + @IsEnum(ApplicationStatus) + @Column({ type: "string", required: false }) + firstChoiceStatus?: ApplicationStatus; + + @ApiProperty({ + enum: ApplicationStatus, + default: ApplicationStatus.PENDING, + required: false, + }) + @IsOptional() + @IsEnum(ApplicationStatus) + @Column({ type: "string", required: false }) + secondChoiceStatus?: ApplicationStatus; + + @ApiProperty({ enum: OrganizerTeam, required: false, nullable: true }) + @IsOptional() + @IsEnum(OrganizerTeam) + @Column({ type: "string", required: false, nullable: true }) + assignedTeam?: OrganizerTeam; + + @ApiProperty({ required: false }) + @IsOptional() + @Column({ type: "string", required: false }) + createdAt?: Date; + + @ApiProperty({ required: false }) + @IsOptional() + @Column({ type: "string", required: false }) + updatedAt?: Date; +} + +export class OrganizerApplicationEntity extends PickType(OrganizerApplication, [ + "id", + "name", + "email", + "yearStanding", + "major", + "firstChoiceTeam", + "secondChoiceTeam", + "resumeUrl", + "whyHackpsu", + "newIdea", + "whatExcitesYou", + "firstChoiceStatus", + "secondChoiceStatus", + "assignedTeam", + "createdAt", + "updatedAt", +] as const) {} diff --git a/src/modules/organizer-application/organizer-application.controller.ts b/src/modules/organizer-application/organizer-application.controller.ts new file mode 100644 index 00000000..d82bad24 --- /dev/null +++ b/src/modules/organizer-application/organizer-application.controller.ts @@ -0,0 +1,184 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseInterceptors, + ValidationPipe, +} from "@nestjs/common"; +import { ApiProperty, ApiTags, OmitType } from "@nestjs/swagger"; +import { InjectRepository, Repository } from "common/objection"; +import { + OrganizerApplication, + OrganizerApplicationEntity, + OrganizerTeam, +} from "entities/organizer-application.entity"; +import { Role, Roles } from "common/gcp"; +import { ApiDoc } from "common/docs"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { UploadedResume } from "./uploaded-resume.decorator"; +import { OrganizerApplicationService } from "./organizer-application.service"; +import { IsEmail, IsEnum, IsString } from "class-validator"; + +class OrganizerApplicationCreateEntity extends OmitType( + OrganizerApplicationEntity, + [ + "id", + "resumeUrl", + "firstChoiceStatus", + "secondChoiceStatus", + "assignedTeam", + "createdAt", + "updatedAt", + ] as const, +) {} + +class ApplicationActionDto { + @ApiProperty({ enum: OrganizerTeam }) + @IsEnum(OrganizerTeam) + team: OrganizerTeam; +} + +@ApiTags("Organizer Applications") +@Controller("organizer-applications") +export class OrganizerApplicationController { + constructor( + @InjectRepository(OrganizerApplication) + private readonly applicationRepo: Repository, + private readonly applicationService: OrganizerApplicationService, + ) {} + + @Post("/") + @UseInterceptors(FileInterceptor("resume")) + @ApiDoc({ + summary: "Submit an organizer application", + request: { + mimeTypes: ["multipart/form-data"], + }, + response: { + created: { type: OrganizerApplicationEntity }, + }, + auth: Role.NONE, + }) + async create( + @Body(new ValidationPipe({ transform: true })) + applicationData: OrganizerApplicationCreateEntity, + @UploadedResume() resume: Express.Multer.File, + ): Promise { + // First create the application without resume URL to get the ID + const application = await this.applicationRepo + .createOne({ + ...applicationData, + resumeUrl: "pending", // Temporary value + }) + .exec(); + + // Upload the resume with the application ID + const resumeUrl = await this.applicationService.uploadResume( + application.id, + applicationData.email, + resume, + ); + + // Update the application with the actual resume URL + return this.applicationRepo.patchOne(application.id, { resumeUrl }).exec(); + } + + @Get("/") + @Roles(Role.EXEC) + @ApiDoc({ + summary: "Get all organizer applications", + response: { + ok: { type: [OrganizerApplicationEntity] }, + }, + auth: Role.EXEC, + }) + async getAll(): Promise { + return this.applicationRepo.findAll().exec(); + } + + @Get("/by-team/:team") + @Roles(Role.TEAM) + @ApiDoc({ + summary: "Get applications for a specific team", + response: { + ok: { + schema: { + type: "object", + properties: { + firstChoiceApplications: { + type: "array", + items: { + $ref: "#/components/schemas/OrganizerApplicationEntity", + }, + }, + secondChoiceApplications: { + type: "array", + items: { + $ref: "#/components/schemas/OrganizerApplicationEntity", + }, + }, + }, + }, + }, + }, + auth: Role.TEAM, + }) + async getByTeam(@Param("team") team: OrganizerTeam): Promise<{ + firstChoiceApplications: OrganizerApplication[]; + secondChoiceApplications: OrganizerApplication[]; + }> { + return this.applicationService.getApplicationsForTeam(team); + } + + @Get("/:id") + @Roles(Role.TEAM) + @ApiDoc({ + summary: "Get a specific application by ID", + response: { + ok: { type: OrganizerApplicationEntity }, + }, + auth: Role.TEAM, + }) + async getOne( + @Param("id", ParseIntPipe) id: number, + ): Promise { + return this.applicationRepo.findOne(id).exec(); + } + + @Patch("/:id/accept") + @Roles(Role.EXEC) + @ApiDoc({ + summary: "Accept an application for a specific team", + response: { + ok: { type: OrganizerApplicationEntity }, + }, + auth: Role.EXEC, + }) + async accept( + @Param("id", ParseIntPipe) id: number, + @Body(new ValidationPipe({ transform: true })) action: ApplicationActionDto, + ): Promise { + return this.applicationService.acceptApplication(id, action.team); + } + + @Patch("/:id/reject") + @Roles(Role.EXEC) + @ApiDoc({ + summary: "Reject an application from a specific team", + response: { + ok: { type: OrganizerApplicationEntity }, + }, + auth: Role.EXEC, + }) + async reject( + @Param("id", ParseIntPipe) id: number, + @Body(new ValidationPipe({ transform: true })) action: ApplicationActionDto, + ): Promise { + return this.applicationService.rejectApplication(id, action.team); + } +} diff --git a/src/modules/organizer-application/organizer-application.module.ts b/src/modules/organizer-application/organizer-application.module.ts new file mode 100644 index 00000000..94f83a52 --- /dev/null +++ b/src/modules/organizer-application/organizer-application.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { ObjectionModule } from "common/objection"; +import { OrganizerApplication } from "entities/organizer-application.entity"; +import { OrganizerApplicationController } from "./organizer-application.controller"; +import { OrganizerApplicationService } from "./organizer-application.service"; + +@Module({ + imports: [ObjectionModule.forFeature([OrganizerApplication])], + controllers: [OrganizerApplicationController], + providers: [OrganizerApplicationService], +}) +export class OrganizerApplicationModule {} diff --git a/src/modules/organizer-application/organizer-application.service.ts b/src/modules/organizer-application/organizer-application.service.ts new file mode 100644 index 00000000..c6440016 --- /dev/null +++ b/src/modules/organizer-application/organizer-application.service.ts @@ -0,0 +1,218 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import * as admin from "firebase-admin"; +import { v4 as uuidv4 } from "uuid"; +import { + OrganizerApplication, + ApplicationStatus, + OrganizerTeam, +} from "entities/organizer-application.entity"; +import { InjectRepository, Repository } from "common/objection"; + +@Injectable() +export class OrganizerApplicationService { + private resumeBucketName = "hackpsu-organizer-applications"; + + constructor( + @InjectRepository(OrganizerApplication) + private readonly applicationRepo: Repository, + ) {} + + private get resumeBucket() { + return admin.storage().bucket(this.resumeBucketName); + } + + async uploadResume( + applicationId: number, + email: string, + file: Express.Multer.File, + ): Promise { + const extension = file.originalname.split(".").pop() || "pdf"; + const resumeId = `${applicationId}_${email}_${uuidv4()}`; + const filename = `resumes/${resumeId}.${extension}`; + const blob = this.resumeBucket.file(filename); + + await blob.save(file.buffer, { + metadata: { + contentType: file.mimetype, + metadata: { + applicationId: applicationId.toString(), + email: email, + uploadedAt: new Date().toISOString(), + }, + }, + }); + + // Return the public URL + return `https://storage.googleapis.com/${this.resumeBucketName}/${filename}`; + } + + async getResumeUrl(resumePath: string): Promise { + return resumePath; + } + + /** + * Accept an application for a specific team. + * Logic: + * - If the team is firstChoiceTeam and firstChoiceStatus is pending, accept it + * - If the team is secondChoiceTeam, secondChoiceStatus is pending, and firstChoiceStatus is rejected, accept it + * - Otherwise, throw an error + */ + async acceptApplication( + applicationId: number, + team: OrganizerTeam, + ): Promise { + const application = await this.applicationRepo + .findOne(applicationId) + .exec(); + + if (!application) { + throw new BadRequestException("Application not found"); + } + + // Case 1: Accepting for first choice team + if (application.firstChoiceTeam === team) { + const status = application.firstChoiceStatus || ApplicationStatus.PENDING; + if (status !== ApplicationStatus.PENDING) { + throw new BadRequestException( + `Cannot accept application ${applicationId} for first choice team ${team}. ` + + `Current status: ${status}`, + ); + } + + return this.applicationRepo + .patchOne(applicationId, { + firstChoiceStatus: ApplicationStatus.ACCEPTED, + assignedTeam: team, + updatedAt: new Date(), + }) + .exec(); + } + + // Case 2: Accepting for second choice team + if (application.secondChoiceTeam === team) { + const firstStatus = + application.firstChoiceStatus || ApplicationStatus.PENDING; + const secondStatus = + application.secondChoiceStatus || ApplicationStatus.PENDING; + + // Can only accept for second choice if first choice rejected + if (firstStatus !== ApplicationStatus.REJECTED) { + throw new BadRequestException( + `Cannot accept application ${applicationId} for second choice team ${team}. ` + + `First choice status must be rejected. Current: ${firstStatus}`, + ); + } + + if (secondStatus !== ApplicationStatus.PENDING) { + throw new BadRequestException( + `Cannot accept application ${applicationId} for second choice team ${team}. ` + + `Current status: ${secondStatus}`, + ); + } + + return this.applicationRepo + .patchOne(applicationId, { + secondChoiceStatus: ApplicationStatus.ACCEPTED, + assignedTeam: team, + updatedAt: new Date(), + }) + .exec(); + } + + // Team doesn't match either first or second choice + throw new BadRequestException( + `Team ${team} is not a choice for application ${applicationId}. ` + + `First choice: ${application.firstChoiceTeam}, second choice: ${application.secondChoiceTeam}`, + ); + } + + /** + * Reject an application from a specific team. + * Logic: + * - If the team is firstChoiceTeam and firstChoiceStatus is pending, reject it (opens second choice) + * - If the team is secondChoiceTeam and secondChoiceStatus is pending, reject it (final rejection) + * - Otherwise, throw an error + */ + async rejectApplication( + applicationId: number, + team: OrganizerTeam, + ): Promise { + const application = await this.applicationRepo + .findOne(applicationId) + .exec(); + + if (!application) { + throw new BadRequestException("Application not found"); + } + + // Case 1: Rejecting from first choice team + if (application.firstChoiceTeam === team) { + const status = application.firstChoiceStatus || ApplicationStatus.PENDING; + if (status !== ApplicationStatus.PENDING) { + throw new BadRequestException( + `Cannot reject application ${applicationId} from first choice team ${team}. ` + + `Current status: ${status}`, + ); + } + + return this.applicationRepo + .patchOne(applicationId, { + firstChoiceStatus: ApplicationStatus.REJECTED, + updatedAt: new Date(), + }) + .exec(); + } + + // Case 2: Rejecting from second choice team + if (application.secondChoiceTeam === team) { + const status = + application.secondChoiceStatus || ApplicationStatus.PENDING; + if (status !== ApplicationStatus.PENDING) { + throw new BadRequestException( + `Cannot reject application ${applicationId} from second choice team ${team}. ` + + `Current status: ${status}`, + ); + } + + return this.applicationRepo + .patchOne(applicationId, { + secondChoiceStatus: ApplicationStatus.REJECTED, + updatedAt: new Date(), + }) + .exec(); + } + + // Team doesn't match either first or second choice + throw new BadRequestException( + `Team ${team} is not a choice for application ${applicationId}. ` + + `First choice: ${application.firstChoiceTeam}, second choice: ${application.secondChoiceTeam}`, + ); + } + + /** + * Get all applications for a specific team, filtering by what stage they're in: + * - For first choice: show applications where firstChoiceStatus is pending + * - For second choice: show applications where firstChoiceStatus is rejected and secondChoiceStatus is pending + */ + async getApplicationsForTeam(team: OrganizerTeam): Promise<{ + firstChoiceApplications: OrganizerApplication[]; + secondChoiceApplications: OrganizerApplication[]; + }> { + // Get applications where this team is the first choice and firstChoiceStatus is pending + const firstChoiceApplications = await OrganizerApplication.query() + .where("firstChoiceTeam", team) + .where("firstChoiceStatus", ApplicationStatus.PENDING); + + // Get applications where this team is the second choice, firstChoiceStatus is rejected, + // and secondChoiceStatus is pending + const secondChoiceApplications = await OrganizerApplication.query() + .where("secondChoiceTeam", team) + .where("firstChoiceStatus", ApplicationStatus.REJECTED) + .where("secondChoiceStatus", ApplicationStatus.PENDING); + + return { + firstChoiceApplications, + secondChoiceApplications, + }; + } +} diff --git a/src/modules/organizer-application/uploaded-resume.decorator.ts b/src/modules/organizer-application/uploaded-resume.decorator.ts new file mode 100644 index 00000000..e747a8df --- /dev/null +++ b/src/modules/organizer-application/uploaded-resume.decorator.ts @@ -0,0 +1,14 @@ +import { ParseFilePipeBuilder, UploadedFile } from "@nestjs/common"; + +export function UploadedResume(): ParameterDecorator { + return UploadedFile( + new ParseFilePipeBuilder() + .addFileTypeValidator({ + fileType: /(pdf|doc|docx)$/i, + }) + .addMaxSizeValidator({ + maxSize: 10 * 1024 * 1024, // 10MB + }) + .build({ fileIsRequired: true }), + ); +} From e30e70679c591410533c435017ddddbf42a97c4e Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 19 Oct 2025 00:39:08 -0400 Subject: [PATCH 2/2] fix issues --- .../20251019033849_add_organizer_applications_table.ts | 5 ++++- .../organizer-application/organizer-application.service.ts | 4 ---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/db/migrations/20251019033849_add_organizer_applications_table.ts b/db/migrations/20251019033849_add_organizer_applications_table.ts index bf78af12..d5b581bb 100644 --- a/db/migrations/20251019033849_add_organizer_applications_table.ts +++ b/db/migrations/20251019033849_add_organizer_applications_table.ts @@ -84,7 +84,10 @@ export async function up(knex: Knex): Promise { // Timestamps table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); - table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + table + .timestamp("updated_at") + .notNullable() + .defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")); // Indexes for efficient querying table.index("email"); diff --git a/src/modules/organizer-application/organizer-application.service.ts b/src/modules/organizer-application/organizer-application.service.ts index c6440016..f3a826ba 100644 --- a/src/modules/organizer-application/organizer-application.service.ts +++ b/src/modules/organizer-application/organizer-application.service.ts @@ -83,7 +83,6 @@ export class OrganizerApplicationService { .patchOne(applicationId, { firstChoiceStatus: ApplicationStatus.ACCEPTED, assignedTeam: team, - updatedAt: new Date(), }) .exec(); } @@ -114,7 +113,6 @@ export class OrganizerApplicationService { .patchOne(applicationId, { secondChoiceStatus: ApplicationStatus.ACCEPTED, assignedTeam: team, - updatedAt: new Date(), }) .exec(); } @@ -158,7 +156,6 @@ export class OrganizerApplicationService { return this.applicationRepo .patchOne(applicationId, { firstChoiceStatus: ApplicationStatus.REJECTED, - updatedAt: new Date(), }) .exec(); } @@ -177,7 +174,6 @@ export class OrganizerApplicationService { return this.applicationRepo .patchOne(applicationId, { secondChoiceStatus: ApplicationStatus.REJECTED, - updatedAt: new Date(), }) .exec(); }