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:
33
backend-nest/src/modules/players/dto/player-action.dto.ts
Normal file
33
backend-nest/src/modules/players/dto/player-action.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsIn, IsOptional, IsInt } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PlayerActionDto {
|
||||
@ApiProperty({ example: '76561198012345678', description: 'Steam ID' })
|
||||
@IsString()
|
||||
steam_id: string;
|
||||
|
||||
@ApiProperty({ example: 'PlayerName', description: 'Player display name' })
|
||||
@IsString()
|
||||
player_name: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'kick',
|
||||
description: 'Type of action',
|
||||
enum: ['kick', 'ban', 'unban', 'warn', 'note'],
|
||||
})
|
||||
@IsIn(['kick', 'ban', 'unban', 'warn', 'note'])
|
||||
action_type: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Toxic behavior', description: 'Reason for action' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1440,
|
||||
description: 'Duration in minutes (for bans)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
duration_minutes?: number;
|
||||
}
|
||||
35
backend-nest/src/modules/players/players.controller.ts
Normal file
35
backend-nest/src/modules/players/players.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@ApiTags('Players')
|
||||
@ApiBearerAuth()
|
||||
@Controller('players')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class PlayersController {
|
||||
constructor(private readonly playersService: PlayersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('players.view')
|
||||
@ApiOperation({ summary: 'Get recent players for this server' })
|
||||
async getPlayers(@CurrentTenant() licenseId: string) {
|
||||
return await this.playersService.getPlayers(licenseId);
|
||||
}
|
||||
|
||||
@Post('action')
|
||||
@RequirePermission('players.moderate')
|
||||
@ApiOperation({ summary: 'Perform a moderation action on a player' })
|
||||
async performAction(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: PlayerActionDto,
|
||||
) {
|
||||
return await this.playersService.performAction(licenseId, userId, dto);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/players/players.module.ts
Normal file
14
backend-nest/src/modules/players/players.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService, NatsService],
|
||||
exports: [PlayersService],
|
||||
})
|
||||
export class PlayersModule {}
|
||||
98
backend-nest/src/modules/players/players.service.ts
Normal file
98
backend-nest/src/modules/players/players.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
export interface Player {
|
||||
steam_id: string;
|
||||
player_name: string;
|
||||
status: 'online' | 'offline' | 'banned';
|
||||
last_seen?: Date;
|
||||
ban_expires?: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlayersService {
|
||||
constructor(
|
||||
@InjectRepository(PlayerAction)
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get recent players for a license
|
||||
*
|
||||
* TODO: This needs a player_sessions table to track online/offline status.
|
||||
* For now, we query player_actions to get a list of players who have had actions.
|
||||
*/
|
||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||
const actions = await this.actionRepo
|
||||
.createQueryBuilder('action')
|
||||
.where('action.license_id = :licenseId', { licenseId })
|
||||
.orderBy('action.created_at', 'DESC')
|
||||
.take(100)
|
||||
.getMany();
|
||||
|
||||
// Group by steam_id to get unique players
|
||||
const playerMap = new Map<string, Player>();
|
||||
|
||||
for (const action of actions) {
|
||||
if (!playerMap.has(action.steam_id)) {
|
||||
// Determine status based on latest action
|
||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
||||
if (action.action_type === 'ban') {
|
||||
status = 'banned';
|
||||
}
|
||||
|
||||
playerMap.set(action.steam_id, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status,
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const players = Array.from(playerMap.values());
|
||||
|
||||
return { players };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a moderation action on a player
|
||||
*/
|
||||
async performAction(
|
||||
licenseId: string,
|
||||
userId: string,
|
||||
dto: PlayerActionDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
// Insert action record
|
||||
const action = this.actionRepo.create({
|
||||
license_id: licenseId,
|
||||
steam_id: dto.steam_id,
|
||||
player_name: dto.player_name,
|
||||
action_type: dto.action_type,
|
||||
reason: dto.reason || null,
|
||||
duration_minutes: dto.duration_minutes || null,
|
||||
performed_by: userId,
|
||||
});
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// For kick/ban, send NATS command to the server
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
duration_minutes: dto.duration_minutes,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user