diff --git a/db/migrations/20250818000001_add_team_devpost_to_projects.ts b/db/migrations/20250818000001_add_team_devpost_to_projects.ts new file mode 100644 index 00000000..b6166d92 --- /dev/null +++ b/db/migrations/20250818000001_add_team_devpost_to_projects.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable("projects", (table) => { + table + .uuid("team_id") + .nullable() + .references("id") + .inTable("teams") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table.string("devpost_link").nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable("projects", (table) => { + table.dropColumn("team_id"); + table.dropColumn("devpost_link"); + }); +} \ No newline at end of file diff --git a/src/entities/project.entity.ts b/src/entities/project.entity.ts index b46c2ff7..b837f4ba 100644 --- a/src/entities/project.entity.ts +++ b/src/entities/project.entity.ts @@ -1,5 +1,5 @@ import { ApiProperty, PickType } from "@nestjs/swagger"; -import { IsOptional, IsString } from "class-validator"; +import { IsOptional, IsString, IsUrl } from "class-validator"; import { Column, ID, Table } from "common/objection"; import { Entity } from "entities/base.entity"; @@ -14,6 +14,14 @@ import { Entity } from "entities/base.entity"; to: "scores.projectId", }, }, + team: { + relation: Entity.BelongsToOneRelation, + modelClass: "team.entity.js", + join: { + from: "projects.teamId", + to: "teams.id", + }, + }, }, modifiers: { projectsByHackathon: async (qb, id) => @@ -42,6 +50,18 @@ export class Project extends Entity { @IsString() @Column({ type: "string", required: false }) categories: string; + + @ApiProperty({ required: false, description: "Team associated with this project" }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false }) + teamId?: string; + + @ApiProperty({ required: false, description: "Devpost submission link" }) + @IsOptional() + @IsUrl({}, { message: "Must be a valid URL" }) + @Column({ type: "string", required: false }) + devpostLink?: string; } export class ProjectEntity extends PickType(Project, [ @@ -49,4 +69,6 @@ export class ProjectEntity extends PickType(Project, [ "name", "hackathonId", "categories", + "teamId", + "devpostLink", ] as const) {} diff --git a/src/modules/judging/judging.module.ts b/src/modules/judging/judging.module.ts index 9aec7f96..36fac975 100644 --- a/src/modules/judging/judging.module.ts +++ b/src/modules/judging/judging.module.ts @@ -8,9 +8,10 @@ import { JudgingController } from "modules/judging/judging.controller"; import { JudgingService } from "modules/judging/judging.service"; import { Organizer } from "entities/organizer.entity"; import { Hackathon } from "entities/hackathon.entity"; +import { Team } from "entities/team.entity"; @Module({ - imports: [ObjectionModule.forFeature([Organizer, Project, Score, Hackathon])], + imports: [ObjectionModule.forFeature([Organizer, Project, Score, Hackathon, Team])], controllers: [JudgingController, ProjectController, ScoreController], providers: [JudgingService], }) diff --git a/src/modules/judging/project.controller.ts b/src/modules/judging/project.controller.ts index e9f7b9fa..a44a41ce 100644 --- a/src/modules/judging/project.controller.ts +++ b/src/modules/judging/project.controller.ts @@ -6,6 +6,7 @@ import { Get, HttpCode, HttpStatus, + NotFoundException, Param, ParseIntPipe, Patch, @@ -18,6 +19,7 @@ import { } from "@nestjs/common"; import { InjectRepository, Repository } from "common/objection"; import { Project, ProjectEntity } from "entities/project.entity"; +import { Team } from "entities/team.entity"; import { ApiTags, OmitType, PartialType } from "@nestjs/swagger"; import { Role, Roles } from "common/gcp"; import { ApiDoc } from "common/docs"; @@ -39,6 +41,8 @@ export class ProjectController { private readonly projectRepo: Repository, @InjectRepository(Hackathon) private readonly hackathonRepo: Repository, + @InjectRepository(Team) + private readonly teamRepo: Repository, ) {} @Get("/") @@ -54,7 +58,7 @@ export class ProjectController { } @Post("/") - @Roles(Role.TEAM) + @Roles(Role.NONE) @ApiDoc({ summary: "Create a Project", request: { @@ -76,6 +80,19 @@ export class ProjectController { ) data: ProjectCreateEntity, ) { + // Validate teamId if provided + if (data.teamId) { + const team = await this.teamRepo.findOne(data.teamId).exec(); + if (!team) { + throw new BadRequestException(`Team with ID ${data.teamId} not found`); + } + + // Check if team is active + if (!team.isActive) { + throw new BadRequestException("Cannot assign project to inactive team"); + } + } + return this.projectRepo.createOne(data).byHackathon(); } @@ -98,6 +115,31 @@ export class ProjectController { return this.projectRepo.findOne(id).exec(); } + @Get("team/:teamId") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Get Projects by Team ID", + params: [ + { + name: "teamId", + description: "ID must be set to a team's ID", + }, + ], + response: { + ok: { type: [ProjectEntity] }, + }, + auth: Role.NONE, + }) + async getProjectsByTeam(@Param("teamId") teamId: string) { + // Validate team exists + const team = await this.teamRepo.findOne(teamId).exec(); + if (!team) { + throw new NotFoundException(`Team with ID ${teamId} not found`); + } + + return this.projectRepo.findAll().byHackathon().where("teamId", teamId); + } + @Patch(":id") @Roles(Role.TEAM) @ApiDoc({ @@ -128,6 +170,19 @@ export class ProjectController { ) data: ProjectPatchEntity, ) { + // Validate teamId if provided + if (data.teamId) { + const team = await this.teamRepo.findOne(data.teamId).exec(); + if (!team) { + throw new BadRequestException(`Team with ID ${data.teamId} not found`); + } + + // Check if team is active + if (!team.isActive) { + throw new BadRequestException("Cannot assign project to inactive team"); + } + } + return this.projectRepo.patchOne(id, data).exec(); }