From b0b326377363db75765f160f055e7989a57e155a Mon Sep 17 00:00:00 2001 From: S-Mubarak Date: Sat, 27 Jun 2026 11:38:45 +0100 Subject: [PATCH] feat: add team/company accounts module --- backend/src/app.module.ts | 2 + backend/src/teams/dto/create-team.dto.ts | 18 ++++ .../src/teams/dto/invite-team-member.dto.ts | 8 ++ backend/src/teams/dto/update-team.dto.ts | 20 ++++ .../src/teams/entities/team-member.entity.ts | 39 ++++++++ backend/src/teams/entities/team.entity.ts | 46 ++++++++++ .../src/teams/enums/team-member-role.enum.ts | 4 + .../teams/providers/create-team.provider.ts | 55 +++++++++++ .../teams/providers/find-my-team.provider.ts | 37 ++++++++ .../providers/find-team-by-id.provider.ts | 36 ++++++++ .../providers/invite-team-member.provider.ts | 92 +++++++++++++++++++ .../providers/remove-team-member.provider.ts | 54 +++++++++++ backend/src/teams/providers/teams.service.ts | 52 +++++++++++ .../teams/providers/update-team.provider.ts | 43 +++++++++ backend/src/teams/teams.controller.ts | 89 ++++++++++++++++++ backend/src/teams/teams.module.ts | 29 ++++++ 16 files changed, 624 insertions(+) create mode 100644 backend/src/teams/dto/create-team.dto.ts create mode 100644 backend/src/teams/dto/invite-team-member.dto.ts create mode 100644 backend/src/teams/dto/update-team.dto.ts create mode 100644 backend/src/teams/entities/team-member.entity.ts create mode 100644 backend/src/teams/entities/team.entity.ts create mode 100644 backend/src/teams/enums/team-member-role.enum.ts create mode 100644 backend/src/teams/providers/create-team.provider.ts create mode 100644 backend/src/teams/providers/find-my-team.provider.ts create mode 100644 backend/src/teams/providers/find-team-by-id.provider.ts create mode 100644 backend/src/teams/providers/invite-team-member.provider.ts create mode 100644 backend/src/teams/providers/remove-team-member.provider.ts create mode 100644 backend/src/teams/providers/teams.service.ts create mode 100644 backend/src/teams/providers/update-team.provider.ts create mode 100644 backend/src/teams/teams.controller.ts create mode 100644 backend/src/teams/teams.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a909a297..6bd441cc 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,7 @@ import { AccessControlModule } from './access-control/access-control.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { EventsModule } from './events/events.module'; import { MembershipPlansModule } from './membership-plans/membership-plans.module'; +import { TeamsModule } from './teams/teams.module'; @Module({ imports: [ @@ -114,6 +115,7 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul WaitlistModule, EventsModule, MembershipPlansModule, + TeamsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/teams/dto/create-team.dto.ts b/backend/src/teams/dto/create-team.dto.ts new file mode 100644 index 00000000..dbbd04cd --- /dev/null +++ b/backend/src/teams/dto/create-team.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsEmail, IsInt, IsOptional, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTeamDto { + @ApiProperty({ example: 'Acme Corp' }) + @IsString() + name: string; + + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @IsInt() + @Min(1) + seatLimit?: number; + + @ApiProperty({ example: 'billing@acme.com' }) + @IsEmail() + billingEmail: string; +} diff --git a/backend/src/teams/dto/invite-team-member.dto.ts b/backend/src/teams/dto/invite-team-member.dto.ts new file mode 100644 index 00000000..5c39487c --- /dev/null +++ b/backend/src/teams/dto/invite-team-member.dto.ts @@ -0,0 +1,8 @@ +import { IsEmail } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class InviteTeamMemberDto { + @ApiProperty({ example: 'jane@acme.com' }) + @IsEmail() + email: string; +} diff --git a/backend/src/teams/dto/update-team.dto.ts b/backend/src/teams/dto/update-team.dto.ts new file mode 100644 index 00000000..6ce9693f --- /dev/null +++ b/backend/src/teams/dto/update-team.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsEmail, IsInt, IsOptional, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTeamDto { + @ApiPropertyOptional({ example: 'Acme Corp' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ example: 20 }) + @IsOptional() + @IsInt() + @Min(1) + seatLimit?: number; + + @ApiPropertyOptional({ example: 'billing@acme.com' }) + @IsOptional() + @IsEmail() + billingEmail?: string; +} diff --git a/backend/src/teams/entities/team-member.entity.ts b/backend/src/teams/entities/team-member.entity.ts new file mode 100644 index 00000000..1be3e6b3 --- /dev/null +++ b/backend/src/teams/entities/team-member.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Unique, +} from 'typeorm'; +import { Team } from './team.entity'; +import { User } from '../../users/entities/user.entity'; +import { TeamMemberRole } from '../enums/team-member-role.enum'; + +@Entity('team_members') +@Unique(['teamId', 'userId']) +export class TeamMember { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + teamId: string; + + @ManyToOne(() => Team, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'teamId' }) + team: Team; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'enum', enum: TeamMemberRole }) + role: TeamMemberRole; + + @CreateDateColumn() + joinedAt: Date; +} diff --git a/backend/src/teams/entities/team.entity.ts b/backend/src/teams/entities/team.entity.ts new file mode 100644 index 00000000..fe3ae0a4 --- /dev/null +++ b/backend/src/teams/entities/team.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { TeamMember } from './team-member.entity'; + +@Entity('teams') +export class Team { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column('uuid') + ownerId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'ownerId' }) + owner: User; + + @Column({ type: 'int', default: 5 }) + seatLimit: number; + + @Column() + billingEmail: string; + + @Column({ default: true }) + isActive: boolean; + + @OneToMany(() => TeamMember, (member) => member.team) + members: TeamMember[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/teams/enums/team-member-role.enum.ts b/backend/src/teams/enums/team-member-role.enum.ts new file mode 100644 index 00000000..d061308f --- /dev/null +++ b/backend/src/teams/enums/team-member-role.enum.ts @@ -0,0 +1,4 @@ +export enum TeamMemberRole { + OWNER = 'OWNER', + MEMBER = 'MEMBER', +} diff --git a/backend/src/teams/providers/create-team.provider.ts b/backend/src/teams/providers/create-team.provider.ts new file mode 100644 index 00000000..ab11212d --- /dev/null +++ b/backend/src/teams/providers/create-team.provider.ts @@ -0,0 +1,55 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; +import { TeamMemberRole } from '../enums/team-member-role.enum'; +import { CreateTeamDto } from '../dto/create-team.dto'; +import { User } from '../../users/entities/user.entity'; + +@Injectable() +export class CreateTeamProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + @InjectRepository(TeamMember) + private readonly teamMembersRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateTeamDto, userId: string): Promise { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + const existing = await this.teamsRepository.findOne({ + where: { ownerId: userId }, + }); + if (existing) { + throw new BadRequestException('You already own a team'); + } + + return this.dataSource.transaction(async (manager) => { + const team = manager.create(Team, { + ...dto, + ownerId: userId, + seatLimit: dto.seatLimit ?? 5, + }); + + const saved = await manager.save(team); + + const member = manager.create(TeamMember, { + teamId: saved.id, + userId, + role: TeamMemberRole.OWNER, + }); + + await manager.save(member); + + return saved; + }); + } +} diff --git a/backend/src/teams/providers/find-my-team.provider.ts b/backend/src/teams/providers/find-my-team.provider.ts new file mode 100644 index 00000000..b5d72078 --- /dev/null +++ b/backend/src/teams/providers/find-my-team.provider.ts @@ -0,0 +1,37 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; + +@Injectable() +export class FindMyTeamProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + @InjectRepository(TeamMember) + private readonly teamMembersRepository: Repository, + ) {} + + async find(userId: string): Promise { + const membership = await this.teamMembersRepository.findOne({ + where: { userId }, + relations: ['team'], + }); + + if (!membership) { + throw new NotFoundException('You are not a member of any team'); + } + + const team = await this.teamsRepository.findOne({ + where: { id: membership.teamId }, + relations: ['owner', 'members', 'members.user'], + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + return team; + } +} diff --git a/backend/src/teams/providers/find-team-by-id.provider.ts b/backend/src/teams/providers/find-team-by-id.provider.ts new file mode 100644 index 00000000..d7865e94 --- /dev/null +++ b/backend/src/teams/providers/find-team-by-id.provider.ts @@ -0,0 +1,36 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; + +@Injectable() +export class FindTeamByIdProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + @InjectRepository(TeamMember) + private readonly teamMembersRepository: Repository, + ) {} + + async find(teamId: string, userId: string): Promise { + const membership = await this.teamMembersRepository.findOne({ + where: { teamId, userId }, + }); + + if (!membership) { + throw new NotFoundException('You are not a member of this team'); + } + + const team = await this.teamsRepository.findOne({ + where: { id: teamId }, + relations: ['owner', 'members', 'members.user'], + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + return team; + } +} diff --git a/backend/src/teams/providers/invite-team-member.provider.ts b/backend/src/teams/providers/invite-team-member.provider.ts new file mode 100644 index 00000000..72cb5217 --- /dev/null +++ b/backend/src/teams/providers/invite-team-member.provider.ts @@ -0,0 +1,92 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; +import { TeamMemberRole } from '../enums/team-member-role.enum'; +import { InviteTeamMemberDto } from '../dto/invite-team-member.dto'; +import { User } from '../../users/entities/user.entity'; +import { EmailService } from '../../email/email.service'; + +@Injectable() +export class InviteTeamMemberProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + @InjectRepository(TeamMember) + private readonly teamMembersRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + private readonly emailService: EmailService, + ) {} + + async invite( + teamId: string, + dto: InviteTeamMemberDto, + currentUserId: string, + ): Promise { + const team = await this.teamsRepository.findOne({ + where: { id: teamId }, + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + if (team.ownerId !== currentUserId) { + throw new BadRequestException('Only the team owner can invite members'); + } + + const invitee = await this.usersRepository.findOne({ + where: { email: dto.email }, + }); + + if (!invitee) { + throw new NotFoundException('User with that email not found'); + } + + if (invitee.id === currentUserId) { + throw new BadRequestException('You cannot invite yourself'); + } + + const existingMember = await this.teamMembersRepository.findOne({ + where: { teamId, userId: invitee.id }, + }); + + if (existingMember) { + throw new ConflictException('User is already a member of this team'); + } + + const memberCount = await this.teamMembersRepository.count({ + where: { teamId }, + }); + + if (memberCount >= team.seatLimit) { + throw new BadRequestException('Team seat limit reached'); + } + + const member = this.teamMembersRepository.create({ + teamId, + userId: invitee.id, + role: TeamMemberRole.MEMBER, + }); + + const saved = await this.teamMembersRepository.save(member); + + this.emailService + .sendTemplateEmail( + invitee.email, + `You've been invited to ${team.name}`, + 'team-invite', + { teamName: team.name }, + ) + .catch(() => void 0); + + return saved; + } +} diff --git a/backend/src/teams/providers/remove-team-member.provider.ts b/backend/src/teams/providers/remove-team-member.provider.ts new file mode 100644 index 00000000..a24d8fae --- /dev/null +++ b/backend/src/teams/providers/remove-team-member.provider.ts @@ -0,0 +1,54 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; +import { TeamMemberRole } from '../enums/team-member-role.enum'; + +@Injectable() +export class RemoveTeamMemberProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + @InjectRepository(TeamMember) + private readonly teamMembersRepository: Repository, + ) {} + + async remove( + teamId: string, + memberUserId: string, + currentUserId: string, + ): Promise { + const team = await this.teamsRepository.findOne({ + where: { id: teamId }, + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + if (team.ownerId !== currentUserId) { + throw new BadRequestException( + 'Only the team owner can remove members', + ); + } + + if (memberUserId === currentUserId) { + throw new BadRequestException('You cannot remove yourself as owner'); + } + + const member = await this.teamMembersRepository.findOne({ + where: { teamId, userId: memberUserId, role: TeamMemberRole.MEMBER }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + await this.teamMembersRepository.remove(member); + } +} diff --git a/backend/src/teams/providers/teams.service.ts b/backend/src/teams/providers/teams.service.ts new file mode 100644 index 00000000..5c3a600b --- /dev/null +++ b/backend/src/teams/providers/teams.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { CreateTeamDto } from '../dto/create-team.dto'; +import { InviteTeamMemberDto } from '../dto/invite-team-member.dto'; +import { UpdateTeamDto } from '../dto/update-team.dto'; +import { Team } from '../entities/team.entity'; +import { TeamMember } from '../entities/team-member.entity'; +import { CreateTeamProvider } from './create-team.provider'; +import { InviteTeamMemberProvider } from './invite-team-member.provider'; +import { FindMyTeamProvider } from './find-my-team.provider'; +import { FindTeamByIdProvider } from './find-team-by-id.provider'; +import { UpdateTeamProvider } from './update-team.provider'; +import { RemoveTeamMemberProvider } from './remove-team-member.provider'; + +@Injectable() +export class TeamsService { + constructor( + private readonly createTeamProvider: CreateTeamProvider, + private readonly inviteTeamMemberProvider: InviteTeamMemberProvider, + private readonly findMyTeamProvider: FindMyTeamProvider, + private readonly findTeamByIdProvider: FindTeamByIdProvider, + private readonly updateTeamProvider: UpdateTeamProvider, + private readonly removeTeamMemberProvider: RemoveTeamMemberProvider, + ) {} + + create(dto: CreateTeamDto, userId: string): Promise { + return this.createTeamProvider.create(dto, userId); + } + + invite( + teamId: string, + dto: InviteTeamMemberDto, + userId: string, + ): Promise { + return this.inviteTeamMemberProvider.invite(teamId, dto, userId); + } + + findMyTeam(userId: string): Promise { + return this.findMyTeamProvider.find(userId); + } + + findById(teamId: string, userId: string): Promise { + return this.findTeamByIdProvider.find(teamId, userId); + } + + update(teamId: string, dto: UpdateTeamDto, userId: string): Promise { + return this.updateTeamProvider.update(teamId, dto, userId); + } + + removeMember(teamId: string, memberUserId: string, userId: string): Promise { + return this.removeTeamMemberProvider.remove(teamId, memberUserId, userId); + } +} diff --git a/backend/src/teams/providers/update-team.provider.ts b/backend/src/teams/providers/update-team.provider.ts new file mode 100644 index 00000000..58c18324 --- /dev/null +++ b/backend/src/teams/providers/update-team.provider.ts @@ -0,0 +1,43 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Team } from '../entities/team.entity'; +import { UpdateTeamDto } from '../dto/update-team.dto'; + +@Injectable() +export class UpdateTeamProvider { + constructor( + @InjectRepository(Team) + private readonly teamsRepository: Repository, + ) {} + + async update( + teamId: string, + dto: UpdateTeamDto, + userId: string, + ): Promise { + const team = await this.teamsRepository.findOne({ + where: { id: teamId }, + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + if (team.ownerId !== userId) { + throw new BadRequestException('Only the team owner can update the team'); + } + + if (dto.seatLimit !== undefined && dto.seatLimit < 1) { + throw new BadRequestException('seatLimit must be at least 1'); + } + + Object.assign(team, dto); + + return this.teamsRepository.save(team); + } +} diff --git a/backend/src/teams/teams.controller.ts b/backend/src/teams/teams.controller.ts new file mode 100644 index 00000000..6cacaf3c --- /dev/null +++ b/backend/src/teams/teams.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamsService } from './providers/teams.service'; +import { CreateTeamDto } from './dto/create-team.dto'; +import { InviteTeamMemberDto } from './dto/invite-team-member.dto'; +import { UpdateTeamDto } from './dto/update-team.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; + +@ApiTags('teams') +@ApiBearerAuth() +@Controller('teams') +export class TeamsController { + constructor(private readonly teamsService: TeamsService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a team' }) + async create( + @Body() dto: CreateTeamDto, + @GetCurrentUser('id') userId: string, + ) { + const team = await this.teamsService.create(dto, userId); + return { message: 'Team created successfully', data: team }; + } + + @Post(':id/invite') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Invite a member by email' }) + async invite( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: InviteTeamMemberDto, + @GetCurrentUser('id') userId: string, + ) { + const member = await this.teamsService.invite(id, dto, userId); + return { message: 'Member invited successfully', data: member }; + } + + @Get('me') + @ApiOperation({ summary: 'Get team for the current user' }) + async findMyTeam(@GetCurrentUser('id') userId: string) { + const team = await this.teamsService.findMyTeam(userId); + return { message: 'Team retrieved successfully', data: team }; + } + + @Get(':id') + @ApiOperation({ summary: 'Get team details with members' }) + async findById( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + ) { + const team = await this.teamsService.findById(id, userId); + return { message: 'Team retrieved successfully', data: team }; + } + + @Patch(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update team name, seatLimit, or billingEmail' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTeamDto, + @GetCurrentUser('id') userId: string, + ) { + const team = await this.teamsService.update(id, dto, userId); + return { message: 'Team updated successfully', data: team }; + } + + @Delete(':id/members/:memberId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Remove a member from the team' }) + async removeMember( + @Param('id', ParseUUIDPipe) id: string, + @Param('memberId', ParseUUIDPipe) memberId: string, + @GetCurrentUser('id') userId: string, + ) { + await this.teamsService.removeMember(id, memberId, userId); + return { message: 'Member removed successfully' }; + } +} diff --git a/backend/src/teams/teams.module.ts b/backend/src/teams/teams.module.ts new file mode 100644 index 00000000..f7d2de87 --- /dev/null +++ b/backend/src/teams/teams.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Team } from './entities/team.entity'; +import { TeamMember } from './entities/team-member.entity'; +import { User } from '../users/entities/user.entity'; +import { TeamsService } from './providers/teams.service'; +import { TeamsController } from './teams.controller'; +import { CreateTeamProvider } from './providers/create-team.provider'; +import { InviteTeamMemberProvider } from './providers/invite-team-member.provider'; +import { FindMyTeamProvider } from './providers/find-my-team.provider'; +import { FindTeamByIdProvider } from './providers/find-team-by-id.provider'; +import { UpdateTeamProvider } from './providers/update-team.provider'; +import { RemoveTeamMemberProvider } from './providers/remove-team-member.provider'; + +@Module({ + imports: [TypeOrmModule.forFeature([Team, TeamMember, User])], + controllers: [TeamsController], + providers: [ + TeamsService, + CreateTeamProvider, + InviteTeamMemberProvider, + FindMyTeamProvider, + FindTeamByIdProvider, + UpdateTeamProvider, + RemoveTeamMemberProvider, + ], + exports: [TeamsService], +}) +export class TeamsModule {}