feat: Complete NestJS backend scaffold — 22 modules, 39 entities, WebSocket gateway
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full backend rewrite from Rust/Axum to NestJS/TypeScript. - 22 feature modules (auth, servers, wipes, maps, plugins, players, console, chat, team, notifications, settings, schedules, analytics, alerts, status, store, webstore, admin, setup, migration, users, licenses) - 39 TypeORM entities matching PostgreSQL schema (12 migrations) - Common infrastructure: JWT/RBAC guards, decorators, exception filter - NATS service with pub/sub/request-reply - Socket.IO WebSocket gateway with NATS bridge - Docker: NestJS Dockerfile + updated docker-compose.yml - Zero compile errors (npx tsc --noEmit clean) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
32
backend-nest/src/modules/team/dto/create-role.dto.ts
Normal file
32
backend-nest/src/modules/team/dto/create-role.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { IsString, IsObject, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@ApiProperty({
|
||||
description: 'Name of the role',
|
||||
example: 'Custom Moderator',
|
||||
})
|
||||
@IsString()
|
||||
role_name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Permissions object for the role',
|
||||
example: {
|
||||
can_manage_server: false,
|
||||
can_manage_plugins: true,
|
||||
can_view_console: true,
|
||||
can_execute_commands: false,
|
||||
},
|
||||
})
|
||||
@IsObject()
|
||||
permissions: Record<string, any>;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Optional role description',
|
||||
example: 'Custom role for moderators with limited permissions',
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
19
backend-nest/src/modules/team/dto/invite-member.dto.ts
Normal file
19
backend-nest/src/modules/team/dto/invite-member.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsEmail, IsString, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class InviteMemberDto {
|
||||
@ApiProperty({
|
||||
description: 'Email address of the user to invite',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Role ID to assign to the invited member',
|
||||
example: '123e4567-e89b-12d3-a456-426614174000',
|
||||
})
|
||||
@IsUUID()
|
||||
@IsString()
|
||||
role_id: string;
|
||||
}
|
||||
4
backend-nest/src/modules/team/dto/update-role.dto.ts
Normal file
4
backend-nest/src/modules/team/dto/update-role.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRoleDto } from './create-role.dto';
|
||||
|
||||
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
|
||||
158
backend-nest/src/modules/team/team.controller.ts
Normal file
158
backend-nest/src/modules/team/team.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { TeamService } from './team.service';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
@ApiTags('team')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('team')
|
||||
export class TeamController {
|
||||
constructor(private readonly teamService: TeamService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get team members and roles',
|
||||
description: 'Returns all team members with their roles and all available roles',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Team data retrieved successfully',
|
||||
})
|
||||
async getTeam(@CurrentTenant() licenseId: string) {
|
||||
return await this.teamService.getTeam(licenseId);
|
||||
}
|
||||
|
||||
@Post('invite')
|
||||
@ApiOperation({
|
||||
summary: 'Invite a team member',
|
||||
description: 'Invite a user by email and assign them a role',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Team member invited successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'User not found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: 'User already a team member',
|
||||
})
|
||||
async inviteMember(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: InviteMemberDto,
|
||||
) {
|
||||
return await this.teamService.inviteMember(licenseId, userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Remove a team member',
|
||||
description: 'Remove a team member by ID',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Team member removed successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Team member not found',
|
||||
})
|
||||
async removeMember(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') memberId: string,
|
||||
) {
|
||||
return await this.teamService.removeMember(licenseId, memberId);
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@ApiOperation({
|
||||
summary: 'Create a custom role',
|
||||
description: 'Create a new custom role for this license',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Role created successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: 'Role name already exists',
|
||||
})
|
||||
async createRole(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateRoleDto,
|
||||
) {
|
||||
return await this.teamService.createRole(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('roles/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Update a role',
|
||||
description: 'Update role permissions and details',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Role updated successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Cannot modify system roles',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Role not found',
|
||||
})
|
||||
async updateRole(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') roleId: string,
|
||||
@Body() dto: UpdateRoleDto,
|
||||
) {
|
||||
return await this.teamService.updateRole(licenseId, roleId, dto);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete a role',
|
||||
description: 'Delete a custom role (cannot delete system roles or roles in use)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Role deleted successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Cannot delete system roles or roles in use',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Role not found',
|
||||
})
|
||||
async deleteRole(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') roleId: string,
|
||||
) {
|
||||
return await this.teamService.deleteRole(licenseId, roleId);
|
||||
}
|
||||
}
|
||||
15
backend-nest/src/modules/team/team.module.ts
Normal file
15
backend-nest/src/modules/team/team.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TeamController } from './team.controller';
|
||||
import { TeamService } from './team.service';
|
||||
import { TeamMember } from '../../entities/team-member.entity';
|
||||
import { Role } from '../../entities/role.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TeamMember, Role, User])],
|
||||
controllers: [TeamController],
|
||||
providers: [TeamService],
|
||||
exports: [TeamService],
|
||||
})
|
||||
export class TeamModule {}
|
||||
260
backend-nest/src/modules/team/team.service.ts
Normal file
260
backend-nest/src/modules/team/team.service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeamMember } from '../../entities/team-member.entity';
|
||||
import { Role } from '../../entities/role.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TeamService {
|
||||
constructor(
|
||||
@InjectRepository(TeamMember)
|
||||
private teamMemberRepository: Repository<TeamMember>,
|
||||
@InjectRepository(Role)
|
||||
private roleRepository: Repository<Role>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async getTeam(licenseId: string) {
|
||||
// Get all team members with joined user and role data
|
||||
const members = await this.teamMemberRepository.find({
|
||||
where: { license_id: licenseId },
|
||||
relations: ['user', 'role'],
|
||||
order: { joined_at: 'DESC' },
|
||||
});
|
||||
|
||||
// Get all roles (system defaults + custom roles for this license)
|
||||
const roles = await this.getRoles(licenseId);
|
||||
|
||||
return {
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
username: member.user?.username,
|
||||
email: member.user?.email,
|
||||
role_id: member.role_id,
|
||||
role_name: member.role?.role_name,
|
||||
joined_at: member.joined_at,
|
||||
invited_by: member.invited_by,
|
||||
})),
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
async inviteMember(
|
||||
licenseId: string,
|
||||
invitedBy: string,
|
||||
dto: InviteMemberDto,
|
||||
) {
|
||||
// Look up user by email
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(
|
||||
`User with email ${dto.email} not found. User must register first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is already a team member
|
||||
const existingMember = await this.teamMemberRepository.findOne({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
user_id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
throw new ConflictException(
|
||||
`User ${dto.email} is already a team member`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify role exists and belongs to this license or is a system default
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: { id: dto.role_id },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role ${dto.role_id} not found`);
|
||||
}
|
||||
|
||||
if (role.license_id !== licenseId && !role.is_system_default) {
|
||||
throw new BadRequestException(
|
||||
'Cannot assign role from another license',
|
||||
);
|
||||
}
|
||||
|
||||
// Create team member entry
|
||||
const teamMember = this.teamMemberRepository.create({
|
||||
license_id: licenseId,
|
||||
user_id: user.id,
|
||||
role_id: dto.role_id,
|
||||
invited_by: invitedBy,
|
||||
joined_at: new Date(),
|
||||
});
|
||||
|
||||
const saved = await this.teamMemberRepository.save(teamMember);
|
||||
|
||||
// Return with joined data
|
||||
const memberWithData = await this.teamMemberRepository.findOne({
|
||||
where: { id: saved.id },
|
||||
relations: ['user', 'role'],
|
||||
});
|
||||
|
||||
if (!memberWithData) {
|
||||
throw new NotFoundException(`Team member ${saved.id} not found after creation`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: memberWithData.id,
|
||||
user_id: memberWithData.user_id,
|
||||
username: memberWithData.user?.username,
|
||||
email: memberWithData.user?.email,
|
||||
role_id: memberWithData.role_id,
|
||||
role_name: memberWithData.role?.role_name,
|
||||
joined_at: memberWithData.joined_at,
|
||||
invited_by: memberWithData.invited_by,
|
||||
};
|
||||
}
|
||||
|
||||
async removeMember(licenseId: string, memberId: string) {
|
||||
const member = await this.teamMemberRepository.findOne({
|
||||
where: {
|
||||
id: memberId,
|
||||
license_id: licenseId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException(`Team member ${memberId} not found`);
|
||||
}
|
||||
|
||||
await this.teamMemberRepository.delete(memberId);
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async getRoles(licenseId: string) {
|
||||
// Get all roles where license_id matches OR is_system_default = true
|
||||
const roles = await this.roleRepository.find({
|
||||
where: [
|
||||
{ license_id: licenseId },
|
||||
{ is_system_default: true },
|
||||
],
|
||||
order: { is_system_default: 'DESC', role_name: 'ASC' },
|
||||
});
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
async createRole(licenseId: string, dto: CreateRoleDto) {
|
||||
// Verify role name doesn't already exist for this license
|
||||
const existing = await this.roleRepository.findOne({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
role_name: dto.role_name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Role with name "${dto.role_name}" already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const role = this.roleRepository.create({
|
||||
license_id: licenseId,
|
||||
role_name: dto.role_name,
|
||||
permissions: dto.permissions,
|
||||
description: dto.description,
|
||||
is_system_default: false,
|
||||
});
|
||||
|
||||
return await this.roleRepository.save(role);
|
||||
}
|
||||
|
||||
async updateRole(licenseId: string, roleId: string, dto: UpdateRoleDto) {
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: { id: roleId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role ${roleId} not found`);
|
||||
}
|
||||
|
||||
// Cannot update system default roles
|
||||
if (role.is_system_default) {
|
||||
throw new BadRequestException('Cannot modify system default roles');
|
||||
}
|
||||
|
||||
// Cannot update roles from other licenses
|
||||
if (role.license_id !== licenseId) {
|
||||
throw new BadRequestException('Cannot modify role from another license');
|
||||
}
|
||||
|
||||
// Check for name conflicts if updating name
|
||||
if (dto.role_name && dto.role_name !== role.role_name) {
|
||||
const existing = await this.roleRepository.findOne({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
role_name: dto.role_name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Role with name "${dto.role_name}" already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(role, dto);
|
||||
return await this.roleRepository.save(role);
|
||||
}
|
||||
|
||||
async deleteRole(licenseId: string, roleId: string) {
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: { id: roleId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role ${roleId} not found`);
|
||||
}
|
||||
|
||||
// Cannot delete system default roles
|
||||
if (role.is_system_default) {
|
||||
throw new BadRequestException('Cannot delete system default roles');
|
||||
}
|
||||
|
||||
// Cannot delete roles from other licenses
|
||||
if (role.license_id !== licenseId) {
|
||||
throw new BadRequestException('Cannot delete role from another license');
|
||||
}
|
||||
|
||||
// Check if role is in use
|
||||
const membersUsingRole = await this.teamMemberRepository.count({
|
||||
where: { role_id: roleId },
|
||||
});
|
||||
|
||||
if (membersUsingRole > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete role: ${membersUsingRole} team member(s) currently assigned to this role`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.roleRepository.delete(roleId);
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user