From b25c02519eeb25e8d85ec2abd538b7c994c53b25 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 21:03:55 +0000 Subject: [PATCH 1/6] chore(deps): update dependency @types/node to v22.17.1 (#240) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9cd0534f..f0d31b4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2025,9 +2025,9 @@ undici-types "~7.10.0" "@types/node@^22.10.5", "@types/node@^22.8.7": - version "22.17.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.17.0.tgz#e8c9090e957bd4d9860efb323eb92d297347eac7" - integrity sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ== + version "22.17.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.17.1.tgz#484a755050497ebc3b37ff5adb7470f2e3ea5f5b" + integrity sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA== dependencies: undici-types "~6.21.0" From 823bec2ec8dd10465e43d8e04e4c4805b546be6c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:33:19 +0000 Subject: [PATCH 2/6] chore(deps): update eslint monorepo to v9.33.0 (#241) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/yarn.lock b/yarn.lock index f0d31b4e..6cb7823b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,15 +373,15 @@ debug "^4.3.1" minimatch "^3.1.2" -"@eslint/config-helpers@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.0.tgz#3e09a90dfb87e0005c7694791e58e97077271286" - integrity sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw== +"@eslint/config-helpers@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" + integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== -"@eslint/core@^0.15.0", "@eslint/core@^0.15.1": - version "0.15.1" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.1.tgz#d530d44209cbfe2f82ef86d6ba08760196dd3b60" - integrity sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA== +"@eslint/core@^0.15.2": + version "0.15.2" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" + integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== dependencies: "@types/json-schema" "^7.0.15" @@ -400,22 +400,22 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.32.0", "@eslint/js@^9.17.0": - version "9.32.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.32.0.tgz#a02916f58bd587ea276876cb051b579a3d75d091" - integrity sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg== +"@eslint/js@9.33.0", "@eslint/js@^9.17.0": + version "9.33.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.33.0.tgz#475c92fdddab59b8b8cab960e3de2564a44bf368" + integrity sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A== "@eslint/object-schema@^2.1.6": version "2.1.6" resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== -"@eslint/plugin-kit@^0.3.4": - version "0.3.4" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz#c6b9f165e94bf4d9fdd493f1c028a94aaf5fc1cc" - integrity sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw== +"@eslint/plugin-kit@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" + integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== dependencies: - "@eslint/core" "^0.15.1" + "@eslint/core" "^0.15.2" levn "^0.4.1" "@fastify/busboy@^3.0.0": @@ -3737,18 +3737,18 @@ eslint-visitor-keys@^4.2.1: integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== eslint@^9.17.0: - version "9.32.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.32.0.tgz#4ea28df4a8dbc454e1251e0f3aed4bcf4ce50a47" - integrity sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg== + version "9.33.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.33.0.tgz#cc186b3d9eb0e914539953d6a178a5b413997b73" + integrity sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.12.1" "@eslint/config-array" "^0.21.0" - "@eslint/config-helpers" "^0.3.0" - "@eslint/core" "^0.15.0" + "@eslint/config-helpers" "^0.3.1" + "@eslint/core" "^0.15.2" "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.32.0" - "@eslint/plugin-kit" "^0.3.4" + "@eslint/js" "9.33.0" + "@eslint/plugin-kit" "^0.3.5" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" From 295cc2df00a3915dc299e08ca73346bcf7f562da Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 05:05:00 +0000 Subject: [PATCH 3/6] fix(deps): update dependency googleapis to v155.0.1 (#242) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6cb7823b..e4135aeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4414,20 +4414,7 @@ globals@^16.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-16.3.0.tgz#66118e765ddaf9e2d880f7e17658543f93f1f667" integrity sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ== -google-auth-library@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.1.0.tgz#5412fa2b7f2c4fe169c6cc56b9425319137e17e9" - integrity sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ== - dependencies: - base64-js "^1.3.0" - ecdsa-sig-formatter "^1.0.11" - gaxios "^7.0.0" - gcp-metadata "^7.0.0" - google-logging-utils "^1.0.0" - gtoken "^8.0.0" - jws "^4.0.0" - -google-auth-library@^10.2.0: +google-auth-library@^10.1.0, google-auth-library@^10.2.0: version "10.2.1" resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.2.1.tgz#9d2513dd92d797cd058a1696011b0cd4f0213b50" integrity sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A== @@ -4492,9 +4479,9 @@ googleapis-common@^8.0.0: url-template "^2.0.8" googleapis@^155.0.0: - version "155.0.0" - resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-155.0.0.tgz#7b0d41ad0cddf206b3e57f83b25420873d1403be" - integrity sha512-aGZrkCjd3UNatRDj/8NFumS3OtvkNVUn3PjeNydXGydi8U751V4KTXFAOivXKgNzMhGZXML6xzdybVVVKGkPJw== + version "155.0.1" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-155.0.1.tgz#53ff7dfcbbbb2cc6be8db26dce726aadb3895e84" + integrity sha512-8FRHMufEkXqoLeqxF6CTezxIupvHuAGWHfBz3KzRgBrha6R8nf1ed1TPmk3/N8GYkCxueRmUziLPiqpshBlSxQ== dependencies: google-auth-library "^10.2.0" googleapis-common "^8.0.0" From 338c25309441874f74a82c74680af1aaeb49f13f Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 10 Aug 2025 00:34:33 +0530 Subject: [PATCH 4/6] chore: trigger build From 37cad91dc4d7b5904408cb8b79448b957dfd1d26 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 10 Aug 2025 03:01:46 +0530 Subject: [PATCH 5/6] add team --- .../20250810024421_create_team_roster.ts | 57 +++ src/app.module.ts | 2 + src/entities/team-roster.entity.ts | 89 +++++ src/modules/teams/teams.controller.ts | 365 ++++++++++++++++++ src/modules/teams/teams.module.ts | 15 + src/modules/teams/teams.service.ts | 335 ++++++++++++++++ 6 files changed, 863 insertions(+) create mode 100644 db/migrations/20250810024421_create_team_roster.ts create mode 100644 src/entities/team-roster.entity.ts create mode 100644 src/modules/teams/teams.controller.ts create mode 100644 src/modules/teams/teams.module.ts create mode 100644 src/modules/teams/teams.service.ts diff --git a/db/migrations/20250810024421_create_team_roster.ts b/db/migrations/20250810024421_create_team_roster.ts new file mode 100644 index 00000000..189af8e3 --- /dev/null +++ b/db/migrations/20250810024421_create_team_roster.ts @@ -0,0 +1,57 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("team_roster", (table) => { + table.uuid("id").primary().notNullable(); // UUID + + table + .string("hackathon_id") + .notNullable() + .references("id") + .inTable("hackathons") + .onDelete("CASCADE") + .onUpdate("CASCADE"); + + table.uuid("team_id").notNullable(); // UUID identifier for the team + table.string("team_name", 80).notNullable(); // Team name (duplicated across rows) + + table + .string("user_id") + .notNullable() + .references("id") + .inTable("users") + .onDelete("CASCADE") + .onUpdate("CASCADE"); + + table.enu("role", ["lead", "member"]).notNullable(); + + table.bigInteger("joined_at").unsigned().notNullable(); + table.bigInteger("updated_at").unsigned().notNullable(); + + // Constraints + table.unique( + ["hackathon_id", "user_id"], + "team_roster_hackathon_user_unique", + ); // One team per user per hackathon + table.unique( + ["hackathon_id", "team_id", "user_id"], + "team_roster_hackathon_team_user_unique", + ); // No duplicate row in same team + + // Indexes + table.index( + ["hackathon_id", "team_id"], + "team_roster_hackathon_team_index", + ); + table.index( + ["hackathon_id", "team_name"], + "team_roster_hackathon_name_index", + ); + table.index(["user_id"], "team_roster_user_index"); + table.index(["team_id"], "team_roster_team_index"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("team_roster"); +} diff --git a/src/app.module.ts b/src/app.module.ts index 022bbc3b..df45987d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { FinanceModule } from "modules/finance/finance.module"; import { WalletModule } from "modules/wallet/wallet.module"; import { EmailModule } from "modules/email/email.module"; import { InventoryModule } from "modules/inventory/inventory.module"; +import { TeamsModule } from "modules/teams/teams.module"; @Module({ imports: [ @@ -98,6 +99,7 @@ import { InventoryModule } from "modules/inventory/inventory.module"; WalletModule, EmailModule, InventoryModule, + TeamsModule, // WebSocket SocketModule, diff --git a/src/entities/team-roster.entity.ts b/src/entities/team-roster.entity.ts new file mode 100644 index 00000000..f6289e7d --- /dev/null +++ b/src/entities/team-roster.entity.ts @@ -0,0 +1,89 @@ +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Column, ID, Table } from "common/objection"; +import { Entity } from "entities/base.entity"; +import { IsEnum, IsString, IsNumber } from "class-validator"; +import { User } from "./user.entity"; +import { Hackathon } from "./hackathon.entity"; + +export enum TeamRole { + LEAD = "lead", + MEMBER = "member", +} + +@Table({ + name: "team_roster", + relationMappings: { + user: { + relation: Entity.BelongsToOneRelation, + modelClass: User, + join: { + from: "team_roster.userId", + to: "users.id", + }, + }, + hackathon: { + relation: Entity.BelongsToOneRelation, + modelClass: Hackathon, + join: { + from: "team_roster.hackathonId", + to: "hackathons.id", + }, + }, + }, +}) +export class TeamRoster extends Entity { + @ApiProperty() + @IsString() + @ID({ type: "string" }) + id: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + hackathonId: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + teamId: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + teamName: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + userId: string; + + @ApiProperty({ enum: TeamRole }) + @IsEnum(TeamRole) + @Column({ type: "string" }) + role: TeamRole; + + @ApiProperty() + @IsNumber() + @Column({ type: "integer" }) + joinedAt: number; + + @ApiProperty() + @IsNumber() + @Column({ type: "integer" }) + updatedAt: number; + + // Relations + user?: User; + hackathon?: Hackathon; +} + +export class TeamRosterEntity extends PickType(TeamRoster, [ + "id", + "hackathonId", + "teamId", + "teamName", + "userId", + "role", + "joinedAt", + "updatedAt", +] as const) {} diff --git a/src/modules/teams/teams.controller.ts b/src/modules/teams/teams.controller.ts new file mode 100644 index 00000000..f718c147 --- /dev/null +++ b/src/modules/teams/teams.controller.ts @@ -0,0 +1,365 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Req, + UnauthorizedException, + UseFilters, + ValidationPipe, +} from "@nestjs/common"; +import { ApiProperty, ApiTags } from "@nestjs/swagger"; +import { Request } from "express"; +import { IsString, IsEmail } from "class-validator"; +import { TeamsService } from "./teams.service"; +import { TeamRosterEntity, TeamRole } from "entities/team-roster.entity"; +import { UserEntity } from "entities/user.entity"; +import { ApiDoc } from "common/docs"; +import { DBExceptionFilter } from "common/filters"; +import { Roles, Role, RestrictedRoles } from "common/gcp"; + +class CreateTeamDto { + @ApiProperty() + @IsString() + teamName: string; +} + +class AddMemberDto { + @ApiProperty() + @IsString() + teamId: string; + + @ApiProperty() + @IsEmail() + userEmail: string; +} + +class ChangeLeadDto { + @ApiProperty() + @IsString() + teamId: string; + + @ApiProperty() + @IsString() + newLeadUserId: string; +} + +class TransferUserDto { + @ApiProperty() + @IsString() + userId: string; + + @ApiProperty() + @IsString() + newTeamId: string; + + @ApiProperty() + @IsString() + newTeamName: string; +} + +class RenameTeamDto { + @ApiProperty() + @IsString() + teamId: string; + + @ApiProperty() + @IsString() + newTeamName: string; +} + +class TeamRosterWithUser extends TeamRosterEntity { + @ApiProperty({ type: UserEntity, nullable: true }) + user?: UserEntity; +} + +class TeamsOverviewItem { + @ApiProperty() + teamId: string; + + @ApiProperty() + teamName: string; + + @ApiProperty() + members: number; +} + +@ApiTags("Teams") +@Controller("teams") +@UseFilters(DBExceptionFilter) +export class TeamsController { + constructor(private readonly teamsService: TeamsService) {} + + @Post("create") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Create a New Team", + request: { + body: { type: CreateTeamDto }, + validate: true, + }, + response: { + created: { type: TeamRosterEntity }, + }, + auth: Role.NONE, + }) + async createTeam( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + data: CreateTeamDto, + @Req() req: Request, + ) { + if (!req.user || !("sub" in req.user)) { + throw new UnauthorizedException(); + } + + const userId = String(req.user.sub); + return this.teamsService.createTeam(userId, data.teamName); + } + + @Post("add-member") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Add Member to Team (Team Lead Only)", + request: { + body: { type: AddMemberDto }, + validate: true, + }, + response: { + created: { type: TeamRosterEntity }, + }, + auth: Role.NONE, + }) + async addMember( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + data: AddMemberDto, + @Req() req: Request, + ) { + if (!req.user || !("sub" in req.user)) { + throw new UnauthorizedException(); + } + + // Check if user is the team lead + const userId = String(req.user.sub); + const userTeam = await this.teamsService.getUserTeam(userId); + if ( + !userTeam || + userTeam.role !== TeamRole.LEAD || + userTeam.teamId !== data.teamId + ) { + throw new UnauthorizedException("Only team leads can add members"); + } + + return this.teamsService.addMemberByEmail(data.teamId, data.userEmail); + } + + @Patch("change-lead") + @RestrictedRoles({ + roles: [Role.NONE], + predicate: (req) => req.user && req.body.newLeadUserId === req.user?.sub, + }) + @Roles(Role.TEAM) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiDoc({ + summary: "Change Team Lead (Current Lead Only)", + request: { + body: { type: ChangeLeadDto }, + validate: true, + }, + response: { + noContent: true, + }, + auth: Role.NONE, + restricted: true, + }) + async changeTeamLead( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + data: ChangeLeadDto, + @Req() req: Request, + ) { + if (!req.user || !("sub" in req.user)) { + throw new UnauthorizedException(); + } + + // Check if user is the current team lead + const userId = String(req.user.sub); + const userTeam = await this.teamsService.getUserTeam(userId); + if ( + !userTeam || + userTeam.role !== TeamRole.LEAD || + userTeam.teamId !== data.teamId + ) { + throw new UnauthorizedException( + "Only current team lead can change leadership", + ); + } + + return this.teamsService.makeTeamLead(data.teamId, data.newLeadUserId); + } + + @Delete("remove-member/:userId") + @RestrictedRoles({ + roles: [Role.NONE], + predicate: (req) => req.user && req.params.userId === req.user?.sub, + }) + @Roles(Role.TEAM) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiDoc({ + summary: "Remove Member from Team", + params: [{ name: "userId", description: "User ID to remove" }], + response: { + noContent: true, + }, + auth: Role.NONE, + restricted: true, + }) + async removeMember(@Param("userId") userId: string) { + return this.teamsService.removeMember(userId); + } + + @Patch("transfer-user") + @Roles(Role.TEAM) + @ApiDoc({ + summary: "Transfer User to Another Team (Organizers Only)", + request: { + body: { type: TransferUserDto }, + validate: true, + }, + response: { + ok: { type: TeamRosterEntity }, + }, + auth: Role.TEAM, + }) + async transferUser( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + data: TransferUserDto, + ) { + return this.teamsService.transferUser( + data.userId, + data.newTeamId, + data.newTeamName, + ); + } + + @Patch("rename") + @Roles(Role.NONE) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiDoc({ + summary: "Rename Team (Team Lead Only)", + request: { + body: { type: RenameTeamDto }, + validate: true, + }, + response: { + noContent: true, + }, + auth: Role.NONE, + }) + async renameTeam( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + data: RenameTeamDto, + @Req() req: Request, + ) { + if (!req.user || !("sub" in req.user)) { + throw new UnauthorizedException(); + } + + // Check if user is the team lead + const userId = String(req.user.sub); + const userTeam = await this.teamsService.getUserTeam(userId); + if ( + !userTeam || + userTeam.role !== TeamRole.LEAD || + userTeam.teamId !== data.teamId + ) { + throw new UnauthorizedException("Only team leads can rename teams"); + } + + return this.teamsService.renameTeam(data.teamId, data.newTeamName); + } + + @Delete(":teamId") + @Roles(Role.TEAM) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiDoc({ + summary: "Delete Team (Organizers Only)", + params: [{ name: "teamId", description: "Team ID to delete" }], + response: { + noContent: true, + }, + auth: Role.TEAM, + }) + async deleteTeam(@Param("teamId") teamId: string) { + return this.teamsService.deleteTeam(teamId); + } + + // Query endpoints + + @Get("user/:userId") + @RestrictedRoles({ + roles: [Role.NONE], + predicate: (req) => req.user && req.params.userId === req.user?.sub, + }) + @Roles(Role.TEAM) + @ApiDoc({ + summary: "Get User's Team", + params: [{ name: "userId", description: "User ID" }], + response: { + ok: { type: TeamRosterWithUser }, + }, + auth: Role.NONE, + restricted: true, + }) + async getUserTeam(@Param("userId") userId: string) { + return this.teamsService.getUserTeam(userId); + } + + @Get("user/me") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Get My Team", + response: { + ok: { type: TeamRosterWithUser }, + }, + auth: Role.NONE, + }) + async getMyTeam(@Req() req: Request) { + if (!req.user || !("sub" in req.user)) { + throw new UnauthorizedException(); + } + + const userId = String(req.user.sub); + return this.teamsService.getUserTeam(userId); + } + + @Get("roster/:teamId") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Get Team Roster", + params: [{ name: "teamId", description: "Team ID" }], + response: { + ok: { type: [TeamRosterWithUser] }, + }, + auth: Role.NONE, + }) + async getTeamRoster(@Param("teamId") teamId: string) { + return this.teamsService.getTeamRoster(teamId); + } + + @Get("overview") + @Roles(Role.TEAM) + @ApiDoc({ + summary: "Get Teams Overview", + response: { + ok: { type: [TeamsOverviewItem] }, + }, + auth: Role.TEAM, + }) + async getTeamsOverview() { + return this.teamsService.getTeamsOverview(); + } +} diff --git a/src/modules/teams/teams.module.ts b/src/modules/teams/teams.module.ts new file mode 100644 index 00000000..a0949cd9 --- /dev/null +++ b/src/modules/teams/teams.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { ObjectionModule } from "common/objection"; +import { TeamRoster } from "entities/team-roster.entity"; +import { User } from "entities/user.entity"; +import { Hackathon } from "entities/hackathon.entity"; +import { TeamsController } from "./teams.controller"; +import { TeamsService } from "./teams.service"; + +@Module({ + imports: [ObjectionModule.forFeature([TeamRoster, User, Hackathon])], + providers: [TeamsService], + controllers: [TeamsController], + exports: [TeamsService], +}) +export class TeamsModule {} diff --git a/src/modules/teams/teams.service.ts b/src/modules/teams/teams.service.ts new file mode 100644 index 00000000..5db8ce55 --- /dev/null +++ b/src/modules/teams/teams.service.ts @@ -0,0 +1,335 @@ +import { + Injectable, + HttpException, + HttpStatus, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository, Repository } from "common/objection"; +import { TeamRoster, TeamRole } from "entities/team-roster.entity"; +import { User } from "entities/user.entity"; +import { Hackathon } from "entities/hackathon.entity"; +import { v4 as uuid } from "uuid"; + +@Injectable() +export class TeamsService { + constructor( + @InjectRepository(TeamRoster) + private readonly teamRosterRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + async createTeam(userId: string, teamName: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + // Check if user already has a team for this hackathon + const existingMembership = await this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("userId", userId) + .first(); + + if (existingMembership) { + throw new HttpException( + "User already has a team for this hackathon", + HttpStatus.CONFLICT, + ); + } + + const teamId = uuid(); + const now = Date.now(); + + return this.teamRosterRepo + .createOne({ + id: uuid(), + hackathonId: hackathon.id, + teamId, + teamName, + userId, + role: TeamRole.LEAD, + joinedAt: now, + updatedAt: now, + }) + .byHackathon(hackathon.id); + } + + async addMemberByEmail( + teamId: string, + userEmail: string, + ): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + // Find user by email + const user = await this.userRepo + .findAll() + .raw() + .where("email", userEmail) + .first(); + + if (!user) { + throw new NotFoundException("User with this email not found"); + } + + // Check if user already has a team for this hackathon + const existingMembership = await this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("userId", user.id) + .first(); + + if (existingMembership) { + throw new HttpException( + "User already has a team for this hackathon", + HttpStatus.CONFLICT, + ); + } + + // Check team size cap and get team info + const currentMembers = await this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("teamId", teamId); + + if (currentMembers.length >= 5) { + throw new HttpException( + "Team is full (max 5 members)", + HttpStatus.CONFLICT, + ); + } + + // Get team name from existing members + const teamName = currentMembers[0]?.teamName; + if (!teamName) { + throw new HttpException("Team not found", HttpStatus.NOT_FOUND); + } + + const now = Date.now(); + + return this.teamRosterRepo + .createOne({ + id: uuid(), + hackathonId: hackathon.id, + teamId, + teamName, + userId: user.id, + role: TeamRole.MEMBER, + joinedAt: now, + updatedAt: now, + }) + .byHackathon(hackathon.id); + } + + async makeTeamLead(teamId: string, newLeadUserId: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + const team = this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("teamId", teamId); + + // Verify new lead is a member of this team + const newLead = await team.clone().where("userId", newLeadUserId).first(); + + if (!newLead) { + throw new HttpException( + "User is not a member of this team", + HttpStatus.BAD_REQUEST, + ); + } + + const trx = await TeamRoster.startTransaction(); + + try { + // Set current lead to member + await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("teamId", teamId) + .where("role", TeamRole.LEAD) + .patch({ role: TeamRole.MEMBER, updatedAt: Date.now() }) + .transacting(trx); + + // Set new lead + await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("teamId", teamId) + .where("userId", newLeadUserId) + .patch({ role: TeamRole.LEAD, updatedAt: Date.now() }) + .transacting(trx); + + await trx.commit(); + } catch (error) { + await trx.rollback(); + throw error; + } + } + + async removeMember(userId: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + const deleted = await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("userId", userId) + .delete(); + + if (deleted === 0) { + throw new HttpException( + "User not found in any team", + HttpStatus.NOT_FOUND, + ); + } + + return deleted; + } + + async transferUser( + userId: string, + newTeamId: string, + newTeamName: string, + ): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + // Check if target team exists and has space + const targetTeamMembers = await this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("teamId", newTeamId); + + if (targetTeamMembers.length >= 5) { + throw new HttpException("Target team is full", HttpStatus.CONFLICT); + } + + const updated = await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("userId", userId) + .patch({ + teamId: newTeamId, + teamName: newTeamName, + role: TeamRole.MEMBER, + updatedAt: Date.now(), + }); + + if (updated === 0) { + throw new HttpException("User not found", HttpStatus.NOT_FOUND); + } + + return this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("userId", userId) + .first(); + } + + async renameTeam(teamId: string, newTeamName: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + const updated = await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("teamId", teamId) + .patch({ + teamName: newTeamName, + updatedAt: Date.now(), + }); + + if (updated === 0) { + throw new HttpException("Team not found", HttpStatus.NOT_FOUND); + } + + return updated; + } + + async deleteTeam(teamId: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + const deleted = await TeamRoster.query() + .where("hackathonId", hackathon.id) + .where("teamId", teamId) + .delete(); + + if (deleted === 0) { + throw new HttpException("Team not found", HttpStatus.NOT_FOUND); + } + + return deleted; + } + + // Query methods + async getUserTeam(userId: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + return ( + this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("userId", userId) + .withGraphFetched("user") + .first() || null + ); + } + + async getTeamRoster(teamId: string): Promise { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + return this.teamRosterRepo + .findAll() + .byHackathon(hackathon.id) + .where("teamId", teamId) + .withGraphFetched("user") + .orderByRaw("CASE WHEN role = 'lead' THEN 0 ELSE 1 END, joined_at"); + } + + async getTeamsOverview() { + // Get active hackathon + const hackathon = await Hackathon.query().findOne({ active: true }); + if (!hackathon) { + throw new NotFoundException("No active hackathon found"); + } + + const results = await TeamRoster.query() + .where("hackathonId", hackathon.id) + .select("teamId", "teamName") + .count("* as members") + .groupBy("teamId", "teamName"); + + return results.map((row: any) => ({ + teamId: row.teamId, + teamName: row.teamName, + members: parseInt(row.members), + })); + } +} From 0df0f6bb6c6355b30d14c779a1caa8a84e8ba03f Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 10 Aug 2025 03:14:24 +0530 Subject: [PATCH 6/6] change --- src/modules/teams/teams.controller.ts | 7 +------ src/modules/teams/teams.service.ts | 8 +++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/modules/teams/teams.controller.ts b/src/modules/teams/teams.controller.ts index f718c147..f29f301d 100644 --- a/src/modules/teams/teams.controller.ts +++ b/src/modules/teams/teams.controller.ts @@ -158,11 +158,7 @@ export class TeamsController { } @Patch("change-lead") - @RestrictedRoles({ - roles: [Role.NONE], - predicate: (req) => req.user && req.body.newLeadUserId === req.user?.sub, - }) - @Roles(Role.TEAM) + @Roles(Role.NONE) @HttpCode(HttpStatus.NO_CONTENT) @ApiDoc({ summary: "Change Team Lead (Current Lead Only)", @@ -174,7 +170,6 @@ export class TeamsController { noContent: true, }, auth: Role.NONE, - restricted: true, }) async changeTeamLead( @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) diff --git a/src/modules/teams/teams.service.ts b/src/modules/teams/teams.service.ts index 5db8ce55..d0e79cee 100644 --- a/src/modules/teams/teams.service.ts +++ b/src/modules/teams/teams.service.ts @@ -54,7 +54,7 @@ export class TeamsService { joinedAt: now, updatedAt: now, }) - .byHackathon(hackathon.id); + .exec(); } async addMemberByEmail( @@ -124,7 +124,7 @@ export class TeamsService { joinedAt: now, updatedAt: now, }) - .byHackathon(hackathon.id); + .exec(); } async makeTeamLead(teamId: string, newLeadUserId: string): Promise { @@ -310,7 +310,9 @@ export class TeamsService { .byHackathon(hackathon.id) .where("teamId", teamId) .withGraphFetched("user") - .orderByRaw("CASE WHEN role = 'lead' THEN 0 ELSE 1 END, joined_at"); + .orderByRaw( + `CASE WHEN role = '${TeamRole.LEAD}' THEN 0 ELSE 1 END, joined_at`, + ); } async getTeamsOverview() {