feat: Complete NestJS backend scaffold — 22 modules, 39 entities, WebSocket gateway
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:
Vantz Stockwell
2026-02-15 21:29:25 -05:00
parent 0f8d0dd14f
commit d20493d533
141 changed files with 13552 additions and 4 deletions

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRoleDto } from './create-role.dto';
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}

View 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);
}
}

View 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 {}

View 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 };
}
}