Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -114,6 +115,7 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul
WaitlistModule,
EventsModule,
MembershipPlansModule,
TeamsModule,
],
controllers: [AppController],
providers: [
Expand Down
18 changes: 18 additions & 0 deletions backend/src/teams/dto/create-team.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions backend/src/teams/dto/invite-team-member.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions backend/src/teams/dto/update-team.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions backend/src/teams/entities/team-member.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
46 changes: 46 additions & 0 deletions backend/src/teams/entities/team.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions backend/src/teams/enums/team-member-role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum TeamMemberRole {
OWNER = 'OWNER',
MEMBER = 'MEMBER',
}
55 changes: 55 additions & 0 deletions backend/src/teams/providers/create-team.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Team>,
@InjectRepository(TeamMember)
private readonly teamMembersRepository: Repository<TeamMember>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly dataSource: DataSource,
) {}

async create(dto: CreateTeamDto, userId: string): Promise<Team> {
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;
});
}
}
37 changes: 37 additions & 0 deletions backend/src/teams/providers/find-my-team.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Team>,
@InjectRepository(TeamMember)
private readonly teamMembersRepository: Repository<TeamMember>,
) {}

async find(userId: string): Promise<Team> {
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;
}
}
36 changes: 36 additions & 0 deletions backend/src/teams/providers/find-team-by-id.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Team>,
@InjectRepository(TeamMember)
private readonly teamMembersRepository: Repository<TeamMember>,
) {}

async find(teamId: string, userId: string): Promise<Team> {
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;
}
}
92 changes: 92 additions & 0 deletions backend/src/teams/providers/invite-team-member.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Team>,
@InjectRepository(TeamMember)
private readonly teamMembersRepository: Repository<TeamMember>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly emailService: EmailService,
) {}

async invite(
teamId: string,
dto: InviteTeamMemberDto,
currentUserId: string,
): Promise<TeamMember> {
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;
}
}
Loading
Loading