diff --git a/db/migrations/20250818000000_add_teams.ts b/db/migrations/20250818000000_add_teams.ts new file mode 100644 index 00000000..664106e2 --- /dev/null +++ b/db/migrations/20250818000000_add_teams.ts @@ -0,0 +1,48 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("teams", (table) => { + table.uuid("id").primary().notNullable(); + table.string("name").notNullable(); + table + .uuid("member1") + .nullable() + .references("id") + .inTable("users") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table + .uuid("member2") + .nullable() + .references("id") + .inTable("users") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table + .uuid("member3") + .nullable() + .references("id") + .inTable("users") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table + .uuid("member4") + .nullable() + .references("id") + .inTable("users") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table + .uuid("member5") + .nullable() + .references("id") + .inTable("users") + .onDelete("SET NULL") + .onUpdate("CASCADE"); + table.boolean("is_active").notNullable().defaultTo(true); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("teams"); +} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 022bbc3b..fcf2e38e 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 { TeamModule } from "modules/team/team.module"; @Module({ imports: [ @@ -98,6 +99,7 @@ import { InventoryModule } from "modules/inventory/inventory.module"; WalletModule, EmailModule, InventoryModule, + TeamModule, // WebSocket SocketModule, diff --git a/src/entities/team.entity.ts b/src/entities/team.entity.ts new file mode 100644 index 00000000..1263ad00 --- /dev/null +++ b/src/entities/team.entity.ts @@ -0,0 +1,65 @@ +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Column, ID, Table } from "common/objection"; +import { Entity } from "entities/base.entity"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; + +@Table({ + name: "teams", +}) +export class Team extends Entity { + @ApiProperty() + @IsString() + @ID({ type: "string" }) + id: string; + + @ApiProperty() + @IsString() + @Column({ type: "string" }) + name: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false, nullable: true }) + member1?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false, nullable: true }) + member2?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false, nullable: true }) + member3?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false, nullable: true }) + member4?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + @Column({ type: "string", required: false, nullable: true }) + member5?: string; + + @ApiProperty() + @IsBoolean() + @Column({ type: "boolean" }) + isActive: boolean; +} + +export class TeamEntity extends PickType(Team, [ + "id", + "name", + "member1", + "member2", + "member3", + "member4", + "member5", + "isActive", +] as const) {} diff --git a/src/modules/team/team.controller.ts b/src/modules/team/team.controller.ts new file mode 100644 index 00000000..2164ca9c --- /dev/null +++ b/src/modules/team/team.controller.ts @@ -0,0 +1,332 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpException, + HttpStatus, + NotFoundException, + Param, + Patch, + Post, + UseFilters, + ValidationPipe, +} from "@nestjs/common"; +import { InjectRepository, Repository } from "common/objection"; +import { Team, TeamEntity } from "entities/team.entity"; +import { User } from "entities/user.entity"; +import { ApiProperty, ApiTags, OmitType, PartialType } from "@nestjs/swagger"; +import { Role, Roles } from "common/gcp"; +import { ApiDoc } from "common/docs"; +import { DBExceptionFilter } from "common/filters"; +import { IsEmail, IsOptional, IsString } from "class-validator"; +import { nanoid } from "nanoid"; + +class TeamCreateEntity extends OmitType(TeamEntity, [ + "id", + "isActive", +] as const) {} + +class TeamUpdateEntity extends PartialType(TeamCreateEntity) { + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + member1?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + member2?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + member3?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + member4?: string; + + @ApiProperty({ type: "string", required: false, nullable: true }) + @IsOptional() + @IsString() + member5?: string; +} + +class AddUserByEmailEntity { + @ApiProperty() + @IsEmail() + email: string; +} + +@ApiTags("Teams") +@Controller("teams") +@UseFilters(DBExceptionFilter) +export class TeamController { + constructor( + @InjectRepository(Team) + private readonly teamRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + @Get("/") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Get All Teams", + response: { + ok: { type: [TeamEntity] }, + }, + auth: Role.TEAM, + }) + async getAll() { + return this.teamRepo.findAll().exec(); + } + + @Post("/") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Create a Team", + request: { + body: { type: TeamCreateEntity }, + validate: true, + }, + response: { + created: { type: TeamEntity }, + }, + auth: Role.TEAM, + }) + async createOne( + @Body( + new ValidationPipe({ + enableDebugMessages: true, + forbidNonWhitelisted: true, + whitelist: true, + transform: true, + }), + ) + data: TeamCreateEntity, + ) { + // Validate user IDs if provided + const memberFields = [ + "member1", + "member2", + "member3", + "member4", + "member5", + ]; + const memberIds = []; + + for (const field of memberFields) { + if (data[field]) { + const user = await this.userRepo.findOne(data[field]).exec(); + if (!user) { + throw new BadRequestException( + `User with ID ${data[field]} not found`, + ); + } + memberIds.push(data[field]); + } + } + + // Check for duplicate members + const uniqueMembers = new Set(memberIds); + if (uniqueMembers.size !== memberIds.length) { + throw new BadRequestException("Duplicate members are not allowed"); + } + + const team = await this.teamRepo + .createOne({ + id: nanoid(), + isActive: true, + ...data, + }) + .exec(); + + return team; + } + + @Get(":id") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Get a Team", + auth: Role.TEAM, + params: [{ name: "id", description: "ID must be set to a team's ID" }], + response: { + ok: { type: TeamEntity }, + }, + }) + async getOne(@Param("id") id: string) { + const team = await this.teamRepo.findOne(id).exec(); + return team; + } + + @Patch(":id") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Update a Team", + params: [ + { + name: "id", + description: "ID must be set to a team's ID", + }, + ], + request: { + body: { type: TeamUpdateEntity }, + validate: true, + }, + response: { + ok: { type: TeamEntity }, + }, + auth: Role.TEAM, + }) + async patchOne( + @Param("id") id: string, + @Body( + new ValidationPipe({ + forbidNonWhitelisted: true, + whitelist: true, + transform: true, + transformOptions: { + exposeUnsetFields: false, + }, + }), + ) + data: TeamUpdateEntity, + ) { + const existingTeam = await this.teamRepo.findOne(id).exec(); + + if (!existingTeam) { + throw new NotFoundException("Team not found"); + } + + if (!existingTeam.isActive) { + throw new BadRequestException("Cannot modify inactive team"); + } + + // Validate user IDs if provided + const memberFields = [ + "member1", + "member2", + "member3", + "member4", + "member5", + ]; + for (const field of memberFields) { + if (data[field]) { + const user = await this.userRepo.findOne(data[field]).exec(); + if (!user) { + throw new BadRequestException( + `User with ID ${data[field]} not found`, + ); + } + } + } + + // Check for duplicate members in the update + const memberIds = memberFields + .map((field) => data[field] || existingTeam[field]) + .filter(Boolean); + + const uniqueMembers = new Set(memberIds); + if (uniqueMembers.size !== memberIds.length) { + throw new BadRequestException("Duplicate members are not allowed"); + } + + // Ensure team doesn't exceed 5 members + if (memberIds.length > 5) { + throw new BadRequestException("Team cannot have more than 5 members"); + } + + const team = await this.teamRepo.patchOne(id, data).exec(); + return team; + } + + @Post(":id/add-user") + @Roles(Role.NONE) + @ApiDoc({ + summary: "Add User to Team by Email", + params: [ + { + name: "id", + description: "ID must be set to a team's ID", + }, + ], + request: { + body: { type: AddUserByEmailEntity }, + validate: true, + }, + response: { + ok: { type: TeamEntity }, + }, + auth: Role.TEAM, + }) + async addUserByEmail( + @Param("id") id: string, + @Body( + new ValidationPipe({ + forbidNonWhitelisted: true, + whitelist: true, + transform: true, + }), + ) + data: AddUserByEmailEntity, + ) { + const team = await this.teamRepo.findOne(id).exec(); + + if (!team) { + throw new NotFoundException("Team not found"); + } + + if (!team.isActive) { + throw new BadRequestException("Cannot modify inactive team"); + } + + // Find user by email + const user = await this.userRepo + .findAll() + .raw() + .where("email", data.email) + .first(); + + if (!user) { + throw new NotFoundException(`User with email ${data.email} not found`); + } + + // Check if user is already in team + const currentMembers = [ + team.member1, + team.member2, + team.member3, + team.member4, + team.member5, + ].filter(Boolean); + + if (currentMembers.includes(user.id)) { + throw new BadRequestException("User is already a member of this team"); + } + + // Find first empty slot + const updateData: Partial = {}; + + if (!team.member1) { + updateData.member1 = user.id; + } else if (!team.member2) { + updateData.member2 = user.id; + } else if (!team.member3) { + updateData.member3 = user.id; + } else if (!team.member4) { + updateData.member4 = user.id; + } else if (!team.member5) { + updateData.member5 = user.id; + } else { + throw new BadRequestException( + "Team is already at maximum capacity (5 members)", + ); + } + + const updatedTeam = await this.teamRepo.patchOne(id, updateData).exec(); + return updatedTeam; + } +} diff --git a/src/modules/team/team.module.ts b/src/modules/team/team.module.ts new file mode 100644 index 00000000..f187e216 --- /dev/null +++ b/src/modules/team/team.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ObjectionModule } from "common/objection"; +import { Team } from "entities/team.entity"; +import { User } from "entities/user.entity"; +import { TeamController } from "./team.controller"; + +@Module({ + imports: [ObjectionModule.forFeature([Team, User])], + controllers: [TeamController], +}) +export class TeamModule {} diff --git a/yarn.lock b/yarn.lock index 44cae5a7..28146453 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2053,9 +2053,9 @@ undici-types "~7.10.0" "@types/node@^22.10.5", "@types/node@^22.8.7": - version "22.17.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.17.1.tgz#484a755050497ebc3b37ff5adb7470f2e3ea5f5b" - integrity sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA== + version "22.17.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.17.2.tgz#47a93d6f4b79327da63af727e7c54e8cab8c4d33" + integrity sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w== dependencies: undici-types "~6.21.0" @@ -3009,9 +3009,9 @@ camelcase@^6.3.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001733: - version "1.0.30001734" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz#f97e08599e2d75664543ae4b6ef25dc2183c5cc6" - integrity sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A== + version "1.0.30001735" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz#ba658fd3fd24a4106fd68d5ce472a2c251494dbe" + integrity sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w== chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" @@ -8006,9 +8006,9 @@ webpack@5.100.2: webpack-sources "^3.3.3" webpack@^5.97.1: - version "5.101.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.1.tgz#bda907efcb233161fe17690ef906a2b244bbfede" - integrity sha512-rHY3vHXRbkSfhG6fH8zYQdth/BtDgXXuR2pHF++1f/EBkI8zkgM5XWfsC3BvOoW9pr1CvZ1qQCxhCEsbNgT50g== + version "5.101.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.2.tgz#08c222b7acfce7da95c593e2f88ea1638a07b344" + integrity sha512-4JLXU0tD6OZNVqlwzm3HGEhAHufSiyv+skb7q0d2367VDMzrU1Q/ZeepvkcHH0rZie6uqEtTQQe0OEOOluH3Mg== dependencies: "@types/eslint-scope" "^3.7.7" "@types/estree" "^1.0.8"