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,24 @@
import { IsString, IsOptional, MaxLength, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateProfileDto {
@ApiProperty({ example: 'Standard Monthly Wipe', maxLength: 100 })
@IsString()
@MaxLength(100)
profile_name: string;
@ApiPropertyOptional({ example: 'Complete wipe with all plugins reset' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ example: { backup: true, notify_players: true } })
@IsOptional()
@IsObject()
pre_wipe_config?: Record<string, any>;
@ApiPropertyOptional({ example: { start_server: true, send_discord_notification: true } })
@IsOptional()
@IsObject()
post_wipe_config?: Record<string, any>;
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsEnum, IsUUID, IsOptional, IsBoolean, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateScheduleDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsUUID()
wipe_profile_id: string;
@ApiProperty({ example: 'Weekly Thursday Wipe', maxLength: 100 })
@IsString()
@MaxLength(100)
schedule_name: string;
@ApiProperty({ example: 'map', enum: ['map', 'blueprint', 'full'] })
@IsEnum(['map', 'blueprint', 'full'])
wipe_type: 'map' | 'blueprint' | 'full';
@ApiProperty({ example: '0 14 * * 4', description: 'Cron expression for schedule' })
@IsString()
@MaxLength(100)
cron_expression: string;
@ApiPropertyOptional({ example: 'America/New_York', default: 'America/New_York' })
@IsOptional()
@IsString()
@MaxLength(50)
timezone?: string;
@ApiPropertyOptional({ example: false, default: false })
@IsOptional()
@IsBoolean()
wipe_blueprints?: boolean;
}

View File

@@ -0,0 +1,13 @@
import { IsEnum, IsUUID, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class TriggerWipeDto {
@ApiProperty({ example: 'map', enum: ['map', 'blueprint', 'full'] })
@IsEnum(['map', 'blueprint', 'full'])
wipe_type: 'map' | 'blueprint' | 'full';
@ApiPropertyOptional({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsOptional()
@IsUUID()
wipe_profile_id?: string;
}

View File

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

View File

@@ -0,0 +1,102 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { WipesService } from './wipes.service';
import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.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('wipes')
@ApiBearerAuth()
@Controller('wipes')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class WipesController {
constructor(private readonly wipesService: WipesService) {}
@Get('profiles')
@RequirePermission('wipe.view')
@ApiOperation({ summary: 'Get all wipe profiles for tenant' })
getProfiles(@CurrentTenant() licenseId: string) {
return this.wipesService.getProfiles(licenseId);
}
@Post('profiles')
@RequirePermission('wipe.manage')
@ApiOperation({ summary: 'Create new wipe profile' })
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateProfileDto) {
return this.wipesService.createProfile(licenseId, dto);
}
@Put('profiles/:id')
@RequirePermission('wipe.manage')
@ApiOperation({ summary: 'Update wipe profile' })
updateProfile(
@CurrentTenant() licenseId: string,
@Param('id') profileId: string,
@Body() dto: UpdateProfileDto,
) {
return this.wipesService.updateProfile(licenseId, profileId, dto);
}
@Delete('profiles/:id')
@RequirePermission('wipe.manage')
@ApiOperation({ summary: 'Delete wipe profile' })
async deleteProfile(@CurrentTenant() licenseId: string, @Param('id') profileId: string) {
await this.wipesService.deleteProfile(licenseId, profileId);
return { deleted: true };
}
@Get('schedules')
@RequirePermission('wipe.view')
@ApiOperation({ summary: 'Get all wipe schedules for tenant' })
getSchedules(@CurrentTenant() licenseId: string) {
return this.wipesService.getSchedules(licenseId);
}
@Post('schedules')
@RequirePermission('wipe.manage')
@ApiOperation({ summary: 'Create new wipe schedule' })
createSchedule(@CurrentTenant() licenseId: string, @Body() dto: CreateScheduleDto) {
return this.wipesService.createSchedule(licenseId, dto);
}
@Get('history')
@RequirePermission('wipe.view')
@ApiOperation({ summary: 'Get wipe history for tenant' })
@ApiQuery({ name: 'limit', required: false, example: 50 })
getHistory(
@CurrentTenant() licenseId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.wipesService.getHistory(licenseId, limit || 50);
}
@Post('trigger')
@RequirePermission('wipe.execute')
@ApiOperation({ summary: 'Trigger manual wipe' })
triggerWipe(@CurrentTenant() licenseId: string, @Body() dto: TriggerWipeDto) {
return this.wipesService.triggerWipe(licenseId, dto);
}
@Post('dry-run')
@RequirePermission('wipe.execute')
@ApiOperation({ summary: 'Simulate wipe and return what would be affected' })
triggerDryRun(@CurrentTenant() licenseId: string, @Body() dto: TriggerWipeDto) {
return this.wipesService.triggerDryRun(licenseId, dto);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WipesController } from './wipes.controller';
import { WipesService } from './wipes.service';
import { WipeProfile } from '../../entities/wipe-profile.entity';
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
@Module({
imports: [TypeOrmModule.forFeature([WipeProfile, WipeSchedule, WipeHistory])],
controllers: [WipesController],
providers: [WipesService],
exports: [WipesService],
})
export class WipesModule {}

View File

@@ -0,0 +1,130 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WipeProfile } from '../../entities/wipe-profile.entity';
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
@Injectable()
export class WipesService {
constructor(
@InjectRepository(WipeProfile)
private readonly wipeProfileRepo: Repository<WipeProfile>,
@InjectRepository(WipeSchedule)
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
) {}
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
return this.wipeProfileRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
});
}
async createProfile(licenseId: string, dto: CreateProfileDto): Promise<WipeProfile> {
const profile = this.wipeProfileRepo.create({
license_id: licenseId,
...dto,
});
return this.wipeProfileRepo.save(profile);
}
async updateProfile(
licenseId: string,
profileId: string,
dto: UpdateProfileDto,
): Promise<WipeProfile> {
const profile = await this.wipeProfileRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) {
throw new NotFoundException(`Wipe profile ${profileId} not found`);
}
Object.assign(profile, dto);
profile.updated_at = new Date();
return this.wipeProfileRepo.save(profile);
}
async deleteProfile(licenseId: string, profileId: string): Promise<void> {
const result = await this.wipeProfileRepo.delete({
id: profileId,
license_id: licenseId,
});
if (result.affected === 0) {
throw new NotFoundException(`Wipe profile ${profileId} not found`);
}
}
async getSchedules(licenseId: string): Promise<WipeSchedule[]> {
return this.wipeScheduleRepo.find({
where: { license_id: licenseId },
relations: ['wipe_profile'],
order: { created_at: 'DESC' },
});
}
async createSchedule(licenseId: string, dto: CreateScheduleDto): Promise<WipeSchedule> {
const schedule = this.wipeScheduleRepo.create({
license_id: licenseId,
...dto,
});
return this.wipeScheduleRepo.save(schedule);
}
async getHistory(licenseId: string, limit: number = 50): Promise<WipeHistory[]> {
return this.wipeHistoryRepo.find({
where: { license_id: licenseId },
relations: ['wipe_profile', 'wipe_schedule', 'map'],
order: { created_at: 'DESC' },
take: limit,
});
}
async triggerWipe(
licenseId: string,
dto: TriggerWipeDto,
): Promise<{ wipe_history_id: string }> {
const history = this.wipeHistoryRepo.create({
license_id: licenseId,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id,
trigger_type: 'manual',
status: 'pending',
});
const saved = await this.wipeHistoryRepo.save(history);
return { wipe_history_id: saved.id };
}
async triggerDryRun(
licenseId: string,
dto: TriggerWipeDto,
): Promise<{
would_delete: string[];
would_preserve: string[];
estimated_duration_seconds: number;
}> {
// Stub implementation - real logic would analyze wipe profile config
const mockResult = {
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
estimated_duration_seconds: 45,
};
if (dto.wipe_type === 'full') {
mockResult.would_delete.push('oxide/data/*');
mockResult.estimated_duration_seconds = 120;
}
return mockResult;
}
}