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,81 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
import { AdminService } from './admin.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@ApiTags('admin')
@ApiBearerAuth()
@UseGuards(SuperAdminGuard)
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('stats')
@ApiOperation({ summary: 'Get platform statistics' })
async getStats() {
return this.adminService.getStats();
}
@Get('licenses')
@ApiOperation({ summary: 'Get paginated list of licenses' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
async getLicenses(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
) {
const p = page ? parseInt(page, 10) : 1;
const l = limit ? parseInt(limit, 10) : 25;
return this.adminService.getLicenses(p, l, search);
}
@Get('licenses/:id')
@ApiOperation({ summary: 'Get license details by ID' })
@ApiParam({ name: 'id', description: 'License ID' })
async getLicenseById(@Param('id') id: string) {
return this.adminService.getLicenseById(id);
}
@Post('licenses')
@ApiOperation({ summary: 'Create a new license' })
async createLicense(@Body('email') email: string) {
return this.adminService.createLicense(email);
}
@Get('users')
@ApiOperation({ summary: 'Get paginated list of users' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const p = page ? parseInt(page, 10) : 1;
const l = limit ? parseInt(limit, 10) : 25;
return this.adminService.getUsers(p, l);
}
@Patch('users/:id')
@ApiOperation({ summary: 'Update user (admin only)' })
@ApiParam({ name: 'id', description: 'User ID' })
async updateUser(
@Param('id') userId: string,
@Body() data: { is_super_admin?: boolean; email_verified?: boolean },
) {
return this.adminService.updateUser(userId, data);
}
@Get('subscriptions')
@ApiOperation({ summary: 'Get all webstore subscriptions' })
async getSubscriptions() {
return this.adminService.getSubscriptions();
}
@Get('servers')
@ApiOperation({ summary: 'Get all server connections' })
async getServers() {
return this.adminService.getServers();
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { WebstoreSubscription } from '../../entities/webstore-subscription.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
User,
License,
ServerConnection,
WebstoreSubscription,
]),
],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,168 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { WebstoreSubscription } from '../../entities/webstore-subscription.entity';
import * as crypto from 'crypto';
import * as argon2 from 'argon2';
@Injectable()
export class AdminService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
@InjectRepository(ServerConnection)
private readonly serverConnectionRepo: Repository<ServerConnection>,
@InjectRepository(WebstoreSubscription)
private readonly webstoreSubRepo: Repository<WebstoreSubscription>,
) {}
async getStats() {
const [totalUsers, totalLicenses, activeServers] = await Promise.all([
this.userRepo.count(),
this.licenseRepo.count(),
this.serverConnectionRepo.count({
where: { connection_status: 'connected' },
}),
]);
return {
total_users: totalUsers,
total_licenses: totalLicenses,
active_servers: activeServers,
};
}
async getLicenses(page: number = 1, limit: number = 25, search?: string) {
const skip = (page - 1) * limit;
const queryBuilder = this.licenseRepo
.createQueryBuilder('license')
.leftJoinAndSelect('license.owner', 'owner')
.orderBy('license.created_at', 'DESC')
.skip(skip)
.take(limit);
if (search) {
queryBuilder.where(
'(license.license_key ILIKE :search OR license.server_name ILIKE :search OR license.subdomain ILIKE :search OR owner.email ILIKE :search)',
{ search: `%${search}%` },
);
}
const [licenses, total] = await queryBuilder.getManyAndCount();
return {
data: licenses,
pagination: {
page,
limit,
total,
total_pages: Math.ceil(total / limit),
},
};
}
async getLicenseById(id: string) {
return this.licenseRepo.findOne({
where: { id },
relations: ['owner'],
});
}
async createLicense(email: string) {
// Find or create user
let user = await this.userRepo.findOne({ where: { email } });
if (!user) {
// Create new user with random password
const randomPassword = crypto.randomBytes(16).toString('hex');
const passwordHash = await argon2.hash(randomPassword);
const username = email.split('@')[0] + '_' + Math.random().toString(36).substr(2, 5);
user = this.userRepo.create({
email,
username,
password_hash: passwordHash,
});
await this.userRepo.save(user);
}
// Create license
const licenseKey = crypto.randomBytes(32).toString('hex');
const license = this.licenseRepo.create({
license_key: licenseKey,
owner_user_id: user.id,
status: 'active',
});
return this.licenseRepo.save(license);
}
async getUsers(page: number = 1, limit: number = 25) {
const skip = (page - 1) * limit;
const [users, total] = await this.userRepo.findAndCount({
order: { created_at: 'DESC' },
skip,
take: limit,
});
return {
data: users.map(u => ({
id: u.id,
email: u.email,
username: u.username,
is_super_admin: u.is_super_admin,
email_verified: u.email_verified,
totp_enabled: u.totp_enabled,
created_at: u.created_at,
last_login_at: u.last_login_at,
})),
pagination: {
page,
limit,
total,
total_pages: Math.ceil(total / limit),
},
};
}
async updateUser(userId: string, data: Partial<User>) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) {
throw new BadRequestException('User not found');
}
// Only allow updating specific fields
if (typeof data.is_super_admin !== 'undefined') {
user.is_super_admin = data.is_super_admin;
}
if (typeof data.email_verified !== 'undefined') {
user.email_verified = data.email_verified;
}
return this.userRepo.save(user);
}
async getSubscriptions() {
return this.webstoreSubRepo.find({
relations: ['license'],
order: { created_at: 'DESC' },
});
}
async getServers() {
return this.serverConnectionRepo
.createQueryBuilder('conn')
.leftJoinAndSelect('conn.license', 'license')
.leftJoinAndSelect('license.owner', 'owner')
.orderBy('conn.created_at', 'DESC')
.getMany();
}
}

View File

@@ -0,0 +1,38 @@
import { Controller, Get, Put, Body, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { AlertsService } from './alerts.service';
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
@ApiTags('alerts')
@ApiBearerAuth()
@Controller('alerts')
export class AlertsController {
constructor(private readonly alertsService: AlertsService) {}
@Get('config')
@ApiOperation({ summary: 'Get alert configuration' })
async getConfig(@CurrentTenant() licenseId: string) {
return this.alertsService.getConfig(licenseId);
}
@Put('config')
@ApiOperation({ summary: 'Update alert configuration' })
async updateConfig(
@CurrentTenant() licenseId: string,
@Body() dto: UpdateAlertConfigDto,
) {
return this.alertsService.updateConfig(licenseId, dto);
}
@Get('history')
@ApiOperation({ summary: 'Get alert history' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max records to return (default: 50)' })
async getHistory(
@CurrentTenant() licenseId: string,
@Query('limit') limit?: string,
) {
const limitNum = limit ? parseInt(limit, 10) : 50;
return this.alertsService.getHistory(licenseId, limitNum);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlertsController } from './alerts.controller';
import { AlertsService } from './alerts.service';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
@Module({
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
controllers: [AlertsController],
providers: [AlertsService],
exports: [AlertsService],
})
export class AlertsModule {}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
@Injectable()
export class AlertsService {
constructor(
@InjectRepository(AlertConfig)
private readonly alertConfigRepo: Repository<AlertConfig>,
@InjectRepository(AlertHistory)
private readonly alertHistoryRepo: Repository<AlertHistory>,
) {}
async getConfig(licenseId: string): Promise<AlertConfig> {
let config = await this.alertConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
// Create default config if not exists
config = this.alertConfigRepo.create({
license_id: licenseId,
population_drop_enabled: true,
population_drop_threshold_percent: 30,
fps_degradation_enabled: true,
fps_threshold: 30,
notify_discord: true,
notify_pushbullet: false,
notify_email: false,
});
await this.alertConfigRepo.save(config);
}
return config;
}
async updateConfig(licenseId: string, dto: UpdateAlertConfigDto): Promise<AlertConfig> {
let config = await this.alertConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
config = this.alertConfigRepo.create({
license_id: licenseId,
...dto,
});
} else {
Object.assign(config, dto);
config.updated_at = new Date();
}
return this.alertConfigRepo.save(config);
}
async getHistory(licenseId: string, limit: number = 50): Promise<AlertHistory[]> {
return this.alertHistoryRepo.find({
where: { license_id: licenseId },
order: { triggered_at: 'DESC' },
take: limit,
});
}
}

View File

@@ -0,0 +1,42 @@
import { IsBoolean, IsInt, IsOptional, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateAlertConfigDto {
@ApiPropertyOptional({ description: 'Enable population drop alerts' })
@IsOptional()
@IsBoolean()
population_drop_enabled?: boolean;
@ApiPropertyOptional({ description: 'Population drop threshold percentage' })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
population_drop_threshold_percent?: number;
@ApiPropertyOptional({ description: 'Enable FPS degradation alerts' })
@IsOptional()
@IsBoolean()
fps_degradation_enabled?: boolean;
@ApiPropertyOptional({ description: 'FPS threshold for alerts' })
@IsOptional()
@IsInt()
@Min(1)
fps_threshold?: number;
@ApiPropertyOptional({ description: 'Send alerts to Discord' })
@IsOptional()
@IsBoolean()
notify_discord?: boolean;
@ApiPropertyOptional({ description: 'Send alerts to Pushbullet' })
@IsOptional()
@IsBoolean()
notify_pushbullet?: boolean;
@ApiPropertyOptional({ description: 'Send alerts via email' })
@IsOptional()
@IsBoolean()
notify_email?: boolean;
}

View File

@@ -0,0 +1,96 @@
import { Controller, Get, Query, Header } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
@ApiTags('analytics')
@ApiBearerAuth()
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('summary')
@ApiOperation({ summary: 'Get analytics summary for time range' })
@ApiQuery({ name: 'range', required: false, type: Number, description: 'Hours to analyze (default: 24)' })
async getSummary(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
) {
const rangeHours = parseInt(range || '24', 10);
return this.analyticsService.getSummary(licenseId, rangeHours);
}
@Get('timeseries')
@ApiOperation({ summary: 'Get timeseries data for charts' })
@ApiQuery({ name: 'range', required: false, type: Number })
@ApiQuery({ name: 'granularity', required: false, enum: ['raw', 'hourly'] })
async getTimeseries(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
@Query('granularity') granularity?: 'raw' | 'hourly',
) {
const rangeHours = parseInt(range || '24', 10);
const gran = granularity || 'hourly';
return this.analyticsService.getTimeseries(licenseId, rangeHours, gran);
}
@Get('wipes/performance')
@ApiOperation({ summary: 'Get wipe performance metrics' })
@ApiQuery({ name: 'range', required: false, type: Number, description: 'Hours (default: 720 = 30 days)' })
async getWipePerformance(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
) {
const rangeHours = parseInt(range || '720', 10);
return this.analyticsService.getWipePerformance(licenseId, rangeHours);
}
@Get('maps')
@ApiOperation({ summary: 'Get map usage analytics' })
@ApiQuery({ name: 'range', required: false, type: Number })
async getMapAnalytics(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
) {
const rangeHours = parseInt(range || '720', 10);
return this.analyticsService.getMapAnalytics(licenseId, rangeHours);
}
@Get('players')
@ApiOperation({ summary: 'Get player analytics' })
@ApiQuery({ name: 'range', required: false, type: Number })
@ApiQuery({ name: 'metric', required: false, enum: ['sessions', 'retention'] })
async getPlayerAnalytics(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
@Query('metric') metric?: 'sessions' | 'retention',
) {
const rangeHours = parseInt(range || '720', 10);
const m = metric || 'sessions';
return this.analyticsService.getPlayerAnalytics(licenseId, rangeHours, m);
}
@Get('retention')
@ApiOperation({ summary: 'Get player retention across wipes' })
@ApiQuery({ name: 'wipe_count', required: false, type: Number, description: 'Number of recent wipes (default: 5)' })
async getRetention(
@CurrentTenant() licenseId: string,
@Query('wipe_count') wipeCount?: string,
) {
const count = parseInt(wipeCount || '5', 10);
return this.analyticsService.getRetention(licenseId, count);
}
@Get('export')
@ApiOperation({ summary: 'Export analytics data as CSV' })
@ApiQuery({ name: 'range', required: false, type: Number })
@Header('Content-Type', 'text/csv')
@Header('Content-Disposition', 'attachment; filename="analytics-export.csv"')
async exportData(
@CurrentTenant() licenseId: string,
@Query('range') range?: string,
) {
const rangeHours = parseInt(range || '24', 10);
return this.analyticsService.exportData(licenseId, rangeHours);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { MapLibrary } from '../../entities/map-library.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ServerStatsHourly,
ServerStats,
WipeHistory,
PlayerSession,
MapLibrary,
]),
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,214 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { MapLibrary } from '../../entities/map-library.entity';
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(ServerStatsHourly)
private readonly statsHourlyRepo: Repository<ServerStatsHourly>,
@InjectRepository(ServerStats)
private readonly statsRepo: Repository<ServerStats>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
@InjectRepository(PlayerSession)
private readonly playerSessionRepo: Repository<PlayerSession>,
@InjectRepository(MapLibrary)
private readonly mapLibraryRepo: Repository<MapLibrary>,
) {}
async getSummary(licenseId: string, rangeHours: number) {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
const stats = await this.statsHourlyRepo
.createQueryBuilder('stats')
.select('MAX(stats.max_players)', 'peak_players')
.addSelect('AVG(stats.avg_players)', 'avg_players')
.addSelect('AVG(stats.uptime_percentage)', 'uptime_percentage')
.where('stats.license_id = :licenseId', { licenseId })
.andWhere('stats.hour >= :cutoff', { cutoff })
.getRawOne();
return {
peak_players: stats?.peak_players || 0,
avg_players: parseFloat(stats?.avg_players || 0),
uptime_percentage: parseFloat(stats?.uptime_percentage || 0),
unique_players: null, // Not implemented yet
};
}
async getTimeseries(licenseId: string, rangeHours: number, granularity: 'raw' | 'hourly') {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
if (granularity === 'hourly') {
const data = await this.statsHourlyRepo.find({
where: { license_id: licenseId, hour: MoreThan(cutoff) },
order: { hour: 'ASC' },
});
return {
timestamps: data.map(d => d.hour),
player_count: data.map(d => d.avg_players),
fps: data.map(d => d.avg_fps),
entity_count: data.map(d => d.avg_entities),
memory_usage_mb: data.map(() => null), // Not in schema
};
} else {
const data = await this.statsRepo.find({
where: { license_id: licenseId, recorded_at: MoreThan(cutoff) },
order: { recorded_at: 'ASC' },
});
return {
timestamps: data.map(d => d.recorded_at),
player_count: data.map(d => d.player_count),
fps: data.map(d => d.fps),
entity_count: data.map(d => d.entity_count),
memory_usage_mb: data.map(d => d.memory_usage_mb),
};
}
}
async getWipePerformance(licenseId: string, rangeHours: number) {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
const wipes = await this.wipeHistoryRepo.find({
where: {
license_id: licenseId,
status: 'success',
started_at: MoreThan(cutoff),
},
order: { started_at: 'DESC' },
});
const durations = wipes
.filter(w => w.started_at && w.completed_at)
.map(w => (w.completed_at!.getTime() - w.started_at!.getTime()) / 1000);
return {
total_wipes: wipes.length,
avg_duration_seconds: durations.length > 0
? durations.reduce((a, b) => a + b, 0) / durations.length
: 0,
min_duration_seconds: durations.length > 0 ? Math.min(...durations) : 0,
max_duration_seconds: durations.length > 0 ? Math.max(...durations) : 0,
wipe_types: wipes.reduce((acc, w) => {
acc[w.wipe_type] = (acc[w.wipe_type] || 0) + 1;
return acc;
}, {} as Record<string, number>),
};
}
async getMapAnalytics(licenseId: string, rangeHours: number) {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
const mapUsage = await this.wipeHistoryRepo
.createQueryBuilder('wipe')
.leftJoinAndSelect('wipe.map', 'map')
.select('map.id', 'map_id')
.addSelect('map.name', 'map_name')
.addSelect('COUNT(wipe.id)', 'usage_count')
.where('wipe.license_id = :licenseId', { licenseId })
.andWhere('wipe.started_at >= :cutoff', { cutoff })
.andWhere('wipe.map_id IS NOT NULL')
.groupBy('map.id')
.addGroupBy('map.name')
.getRawMany();
return {
map_usage: mapUsage.map(m => ({
map_id: m.map_id,
map_name: m.map_name,
usage_count: parseInt(m.usage_count),
})),
};
}
async getPlayerAnalytics(licenseId: string, rangeHours: number, metric: 'sessions' | 'retention') {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
if (metric === 'sessions') {
const sessions = await this.playerSessionRepo.find({
where: {
license_id: licenseId,
session_start: MoreThan(cutoff),
},
order: { session_start: 'DESC' },
});
const totalDuration = sessions
.filter(s => s.duration_seconds)
.reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
return {
total_sessions: sessions.length,
avg_session_duration: sessions.length > 0 ? totalDuration / sessions.length : 0,
unique_players: new Set(sessions.map(s => s.steam_id)).size,
};
}
return { message: 'Retention metric not implemented' };
}
async getRetention(licenseId: string, wipeCount: number) {
const recentWipes = await this.wipeHistoryRepo.find({
where: { license_id: licenseId, status: 'success' },
order: { started_at: 'DESC' },
take: wipeCount,
});
if (recentWipes.length === 0) {
return { wipe_count: 0, retention_data: [] };
}
const retentionData = await Promise.all(
recentWipes.map(async (wipe) => {
const wipeDate = wipe.started_at;
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
const endDate = nextWipe?.started_at || new Date();
const sessionsInPeriod = await this.playerSessionRepo.find({
where: {
license_id: licenseId,
session_start: MoreThan(wipeDate!),
},
});
const uniquePlayers = new Set(sessionsInPeriod.map(s => s.steam_id)).size;
return {
wipe_date: wipeDate,
unique_players: uniquePlayers,
total_sessions: sessionsInPeriod.length,
};
}),
);
return {
wipe_count: recentWipes.length,
retention_data: retentionData,
};
}
async exportData(licenseId: string, rangeHours: number): Promise<string> {
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
const stats = await this.statsRepo.find({
where: { license_id: licenseId, recorded_at: MoreThan(cutoff) },
order: { recorded_at: 'ASC' },
});
// Generate CSV
const headers = 'timestamp,player_count,fps,entity_count,memory_mb\n';
const rows = stats.map(s =>
`${s.recorded_at.toISOString()},${s.player_count},${s.fps},${s.entity_count},${s.memory_usage_mb}`
).join('\n');
return headers + rows;
}
}

View File

@@ -0,0 +1,40 @@
import { Controller, Get, Put, Param, Body, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { ChatService } from './chat.service';
import { FlagMessageDto } from './dto/flag-message.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('Chat')
@ApiBearerAuth()
@Controller('chat')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get()
@RequirePermission('chat.view')
@ApiOperation({ summary: 'Get recent chat messages' })
@ApiQuery({ name: 'limit', required: false, example: 100 })
async getMessages(
@CurrentTenant() licenseId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
) {
return await this.chatService.getMessages(licenseId, limit || 100);
}
@Put(':id/flag')
@RequirePermission('chat.moderate')
@ApiOperation({ summary: 'Flag or unflag a chat message' })
async flagMessage(
@CurrentTenant() licenseId: string,
@CurrentUser('sub') userId: string,
@Param('id') messageId: string,
@Body() dto: FlagMessageDto,
) {
return await this.chatService.flagMessage(licenseId, messageId, userId, dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';
import { ChatLog } from '../../entities/chat-log.entity';
@Module({
imports: [TypeOrmModule.forFeature([ChatLog])],
controllers: [ChatController],
providers: [ChatService],
exports: [ChatService],
})
export class ChatModule {}

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ChatLog } from '../../entities/chat-log.entity';
import { FlagMessageDto } from './dto/flag-message.dto';
@Injectable()
export class ChatService {
constructor(
@InjectRepository(ChatLog)
private readonly chatRepo: Repository<ChatLog>,
) {}
/**
* Get recent chat messages for a license
*/
async getMessages(licenseId: string, limit: number = 100) {
const messages = await this.chatRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
take: limit,
});
// Return in chronological order (oldest first for display)
return { messages: messages.reverse() };
}
/**
* Flag or unflag a chat message
*/
async flagMessage(
licenseId: string,
messageId: string,
userId: string,
dto: FlagMessageDto,
) {
const message = await this.chatRepo.findOne({
where: { id: messageId, license_id: licenseId },
});
if (!message) {
throw new NotFoundException('Message not found');
}
message.flagged = dto.flagged;
message.flagged_by = dto.flagged ? userId : null;
message.flag_reason = dto.flagged ? (dto.flag_reason || null) : null;
return await this.chatRepo.save(message);
}
}

View File

@@ -0,0 +1,13 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class FlagMessageDto {
@ApiProperty({ example: true, description: 'Whether to flag or unflag the message' })
@IsBoolean()
flagged: boolean;
@ApiPropertyOptional({ example: 'Inappropriate language', description: 'Reason for flagging' })
@IsOptional()
@IsString()
flag_reason?: string;
}

View File

@@ -0,0 +1,116 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { NatsService } from '../../services/nats.service';
/**
* Console Gateway
*
* Provides real-time WebSocket connectivity for server console I/O.
* Clients connect with JWT token in query params, join a room by license_id,
* and can send/receive console commands and output.
*/
@WebSocketGateway({ namespace: '/ws', cors: true })
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(ConsoleGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly natsService: NatsService,
) {}
/**
* Handle client connection
* Extract JWT from query param, validate, and join room by license_id
*/
async handleConnection(client: Socket) {
try {
const token = client.handshake.query.token as string;
if (!token) {
throw new UnauthorizedException('No token provided');
}
// Verify JWT
const payload = this.jwtService.verify(token);
const licenseId = payload.license_id;
if (!licenseId) {
throw new UnauthorizedException('Invalid token: no license_id');
}
// Store license_id on socket for later use
client.data.licenseId = licenseId;
client.data.userId = payload.sub;
// Join room specific to this license
await client.join(licenseId);
this.logger.log(`Client ${client.id} connected to license ${licenseId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Connection failed: ${message}`);
client.disconnect();
}
}
/**
* Handle client disconnection
*/
handleDisconnect(client: Socket) {
const licenseId = client.data.licenseId;
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`);
}
/**
* Handle console input from client
* Forward the command to NATS for execution on the game server
*/
@SubscribeMessage('console_input')
async handleConsoleInput(
@ConnectedSocket() client: Socket,
@MessageBody() data: { command: string },
) {
const licenseId = client.data.licenseId;
if (!data.command) {
return { error: 'Command is required' };
}
this.logger.debug(`Console input from ${licenseId}: ${data.command}`);
// Forward to NATS
await this.natsService.sendServerCommand(licenseId, 'command', {
command: data.command,
});
return { success: true };
}
/**
* Send console output or event to all clients in a license room
*/
sendToLicense(licenseId: string, event: string, data: any) {
this.server.to(licenseId).emit(event, data);
}
/**
* Broadcast console output to a specific license
* This method would be called by a NATS subscriber when output is received
*/
broadcastConsoleOutput(licenseId: string, output: string) {
this.sendToLicense(licenseId, 'console_output', { output });
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConsoleGateway } from './console.gateway';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') || 'dev-secret',
signOptions: { expiresIn: '24h' },
}),
}),
],
providers: [ConsoleGateway, NatsService],
exports: [ConsoleGateway],
})
export class ConsoleModule {}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { MigrationService } from './migration.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('migration')
@ApiBearerAuth()
@Controller('migration')
export class MigrationController {
constructor(private readonly migrationService: MigrationService) {}
@Post('export')
@ApiOperation({ summary: 'Export server configuration' })
async exportConfig(
@CurrentTenant() licenseId: string,
@CurrentUser('sub') userId: string,
@Body('export_type') exportType?: string,
) {
return this.migrationService.exportConfig(licenseId, userId, exportType || 'full');
}
@Get('exports')
@ApiOperation({ summary: 'Get export history' })
async getExports(@CurrentTenant() licenseId: string) {
return this.migrationService.getExports(licenseId);
}
@Post('import')
@ApiOperation({ summary: 'Import server configuration' })
async importConfig(
@CurrentTenant() licenseId: string,
@Body() data: any,
) {
return this.migrationService.importConfig(licenseId, data);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrationController } from './migration.controller';
import { MigrationService } from './migration.service';
import { MigrationExport } from '../../entities/migration-export.entity';
@Module({
imports: [TypeOrmModule.forFeature([MigrationExport])],
controllers: [MigrationController],
providers: [MigrationService],
exports: [MigrationService],
})
export class MigrationModule {}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MigrationExport } from '../../entities/migration-export.entity';
@Injectable()
export class MigrationService {
constructor(
@InjectRepository(MigrationExport)
private readonly exportRepo: Repository<MigrationExport>,
) {}
async exportConfig(licenseId: string, userId: string, exportType: string = 'full'): Promise<MigrationExport> {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiry
const exportRecord = this.exportRepo.create({
license_id: licenseId,
export_type: exportType,
storage_path: `/exports/${licenseId}/${Date.now()}.json`,
file_size_bytes: 0, // Stub - would calculate after actual export
created_by: userId,
expires_at: expiresAt,
});
return this.exportRepo.save(exportRecord);
}
async getExports(licenseId: string): Promise<MigrationExport[]> {
return this.exportRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
});
}
async importConfig(licenseId: string, data: any): Promise<{ message: string }> {
// Stub implementation - would validate and import data in production
return { message: 'Import complete' };
}
}

View File

@@ -0,0 +1,131 @@
import { IsBoolean, IsString, IsOptional, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateConfigDto {
@ApiProperty({
description: 'Enable Discord notifications',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
discord_enabled?: boolean;
@ApiProperty({
description: 'Discord webhook URL',
example: 'https://discord.com/api/webhooks/...',
required: false,
})
@IsUrl()
@IsString()
@IsOptional()
discord_webhook_url?: string;
@ApiProperty({
description: 'Enable email notifications',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
email_enabled?: boolean;
@ApiProperty({
description: 'Email address for notifications',
example: 'admin@example.com',
required: false,
})
@IsString()
@IsOptional()
email_address?: string;
@ApiProperty({
description: 'Enable Pushbullet notifications',
example: false,
required: false,
})
@IsBoolean()
@IsOptional()
pushbullet_enabled?: boolean;
@ApiProperty({
description: 'Pushbullet API key',
example: 'o.xxxxxxxxxxxxx',
required: false,
})
@IsString()
@IsOptional()
pushbullet_api_key?: string;
@ApiProperty({
description: 'Notify on server start',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_start?: boolean;
@ApiProperty({
description: 'Notify on server stop',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_stop?: boolean;
@ApiProperty({
description: 'Notify on server crash',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_crash?: boolean;
@ApiProperty({
description: 'Notify on wipe start',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_wipe_start?: boolean;
@ApiProperty({
description: 'Notify on wipe complete',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_wipe_complete?: boolean;
@ApiProperty({
description: 'Notify on wipe failure',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_wipe_failure?: boolean;
@ApiProperty({
description: 'Notify on player count threshold',
example: false,
required: false,
})
@IsBoolean()
@IsOptional()
notify_on_player_threshold?: boolean;
@ApiProperty({
description: 'Player count threshold',
example: '100',
required: false,
})
@IsString()
@IsOptional()
player_threshold?: string;
}

View File

@@ -0,0 +1,48 @@
import { Controller, Get, Put, Body, 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 { NotificationsService } from './notifications.service';
import { UpdateConfigDto } from './dto/update-config.dto';
@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get('config')
@ApiOperation({
summary: 'Get notification configuration',
description: 'Returns notification settings for this license',
})
@ApiResponse({
status: 200,
description: 'Notification config retrieved successfully',
})
async getConfig(@CurrentTenant() licenseId: string) {
return await this.notificationsService.getConfig(licenseId);
}
@Put('config')
@ApiOperation({
summary: 'Update notification configuration',
description: 'Update notification settings for this license',
})
@ApiResponse({
status: 200,
description: 'Notification config updated successfully',
})
async updateConfig(
@CurrentTenant() licenseId: string,
@Body() dto: UpdateConfigDto,
) {
return await this.notificationsService.updateConfig(licenseId, dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationsConfig } from '../../entities/notifications-config.entity';
@Module({
imports: [TypeOrmModule.forFeature([NotificationsConfig])],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotificationsConfig } from '../../entities/notifications-config.entity';
import { UpdateConfigDto } from './dto/update-config.dto';
@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(NotificationsConfig)
private configRepository: Repository<NotificationsConfig>,
) {}
async getConfig(licenseId: string): Promise<NotificationsConfig> {
let config = await this.configRepository.findOne({
where: { license_id: licenseId },
});
// Create default config if not exists
if (!config) {
config = this.configRepository.create({
license_id: licenseId,
discord_enabled: false,
discord_webhook_url: null,
email_enabled: false,
email_address: null,
pushbullet_enabled: false,
pushbullet_api_key: null,
notify_on_start: true,
notify_on_stop: true,
notify_on_crash: true,
notify_on_wipe_start: true,
notify_on_wipe_complete: true,
notify_on_wipe_failure: true,
notify_on_player_threshold: false,
player_threshold: null,
});
config = await this.configRepository.save(config);
}
return config;
}
async updateConfig(
licenseId: string,
dto: UpdateConfigDto,
): Promise<NotificationsConfig> {
// Ensure config exists first
let config = await this.getConfig(licenseId);
// Update fields
Object.assign(config, dto);
return await this.configRepository.save(config);
}
}

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

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

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

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

View File

@@ -0,0 +1,20 @@
import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class InstallPluginDto {
@ApiProperty({ example: 'Kits', maxLength: 255 })
@IsString()
@MaxLength(255)
plugin_name: string;
@ApiPropertyOptional({ example: 'kits', maxLength: 255 })
@IsOptional()
@IsString()
@MaxLength(255)
umod_slug?: string;
@ApiPropertyOptional({ example: 'umod', enum: ['umod', 'manual', 'corrosion_module'], default: 'manual' })
@IsOptional()
@IsEnum(['umod', 'manual', 'corrosion_module'])
source?: 'umod' | 'manual' | 'corrosion_module';
}

View File

@@ -0,0 +1,9 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SearchUmodDto {
@ApiProperty({ example: 'kits', minLength: 2 })
@IsString()
@MinLength(2)
query: string;
}

View File

@@ -0,0 +1,29 @@
import { IsObject, IsBoolean, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePluginConfigDto {
@ApiPropertyOptional({ example: { enabled: true, max_kits: 5 } })
@IsOptional()
@IsObject()
config_json?: Record<string, any>;
@ApiPropertyOptional({ example: false, description: 'Wipe plugin data on map wipe' })
@IsOptional()
@IsBoolean()
wipe_on_map?: boolean;
@ApiPropertyOptional({ example: false, description: 'Wipe plugin data on blueprint wipe' })
@IsOptional()
@IsBoolean()
wipe_on_bp?: boolean;
@ApiPropertyOptional({ example: true, description: 'Wipe plugin data on full wipe' })
@IsOptional()
@IsBoolean()
wipe_on_full?: boolean;
@ApiPropertyOptional({ example: false, description: 'Never wipe this plugin data' })
@IsOptional()
@IsBoolean()
never_wipe?: boolean;
}

View File

@@ -0,0 +1,65 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { PluginsService } from './plugins.service';
import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.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('plugins')
@ApiBearerAuth()
@Controller('plugins')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PluginsController {
constructor(private readonly pluginsService: PluginsService) {}
@Get()
@RequirePermission('plugin.view')
@ApiOperation({ summary: 'Get all installed plugins for tenant' })
getPlugins(@CurrentTenant() licenseId: string) {
return this.pluginsService.getPlugins(licenseId);
}
@Post('install')
@RequirePermission('plugin.manage')
@ApiOperation({ summary: 'Install new plugin' })
installPlugin(@CurrentTenant() licenseId: string, @Body() dto: InstallPluginDto) {
return this.pluginsService.installPlugin(licenseId, dto);
}
@Delete(':id')
@RequirePermission('plugin.manage')
@ApiOperation({ summary: 'Uninstall plugin' })
async uninstallPlugin(@CurrentTenant() licenseId: string, @Param('id') pluginId: string) {
await this.pluginsService.uninstallPlugin(licenseId, pluginId);
return { deleted: true };
}
@Post(':id/reload')
@RequirePermission('plugin.manage')
@ApiOperation({ summary: 'Reload plugin on game server' })
reloadPlugin(@CurrentTenant() licenseId: string, @Param('id') pluginId: string) {
return this.pluginsService.reloadPlugin(licenseId, pluginId);
}
@Put(':id/config')
@RequirePermission('plugin.manage')
@ApiOperation({ summary: 'Update plugin configuration' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') pluginId: string,
@Body() dto: UpdatePluginConfigDto,
) {
return this.pluginsService.updateConfig(licenseId, pluginId, dto);
}
@Get('search')
@RequirePermission('plugin.view')
@ApiOperation({ summary: 'Search uMod plugin directory' })
@ApiQuery({ name: 'q', required: true, example: 'kits' })
searchUmod(@Query('q') query: string) {
return this.pluginsService.searchUmod(query);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PluginsController } from './plugins.controller';
import { PluginsService } from './plugins.service';
import { PluginRegistry } from '../../entities/plugin-registry.entity';
@Module({
imports: [TypeOrmModule.forFeature([PluginRegistry])],
controllers: [PluginsController],
providers: [PluginsService],
exports: [PluginsService],
})
export class PluginsModule {}

View File

@@ -0,0 +1,98 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PluginRegistry } from '../../entities/plugin-registry.entity';
import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
@Injectable()
export class PluginsService {
constructor(
@InjectRepository(PluginRegistry)
private readonly pluginRegistryRepo: Repository<PluginRegistry>,
) {}
async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
return this.pluginRegistryRepo.find({
where: { license_id: licenseId },
order: { installed_at: 'DESC' },
});
}
async installPlugin(licenseId: string, dto: InstallPluginDto): Promise<PluginRegistry> {
// Check if plugin already exists
const existing = await this.pluginRegistryRepo.findOne({
where: {
license_id: licenseId,
plugin_name: dto.plugin_name,
},
});
if (existing) {
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
}
const plugin = this.pluginRegistryRepo.create({
license_id: licenseId,
plugin_name: dto.plugin_name,
umod_slug: dto.umod_slug,
source: dto.source || 'manual',
is_installed: true,
is_loaded: false,
});
return this.pluginRegistryRepo.save(plugin);
}
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
const result = await this.pluginRegistryRepo.delete({
id: pluginId,
license_id: licenseId,
});
if (result.affected === 0) {
throw new NotFoundException(`Plugin ${pluginId} not found`);
}
}
async reloadPlugin(
licenseId: string,
pluginId: string,
): Promise<{ reloaded: boolean; plugin_name: string }> {
const plugin = await this.pluginRegistryRepo.findOne({
where: { id: pluginId, license_id: licenseId },
});
if (!plugin) {
throw new NotFoundException(`Plugin ${pluginId} not found`);
}
// Stub implementation - in production would trigger NATS command
// to reload plugin on game server
return { reloaded: true, plugin_name: plugin.plugin_name };
}
async updateConfig(
licenseId: string,
pluginId: string,
dto: UpdatePluginConfigDto,
): Promise<PluginRegistry> {
const plugin = await this.pluginRegistryRepo.findOne({
where: { id: pluginId, license_id: licenseId },
});
if (!plugin) {
throw new NotFoundException(`Plugin ${pluginId} not found`);
}
Object.assign(plugin, dto);
plugin.updated_at = new Date();
return this.pluginRegistryRepo.save(plugin);
}
async searchUmod(query: string): Promise<any[]> {
// Stub implementation - in production would proxy to uMod API
// or use cached plugin directory
return [];
}
}

View File

@@ -0,0 +1,54 @@
import { IsString, IsEnum, IsOptional, IsObject, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export enum TaskType {
RESTART = 'restart',
ANNOUNCEMENT = 'announcement',
COMMAND = 'command',
PLUGIN_RELOAD = 'plugin_reload',
}
export class CreateTaskDto {
@ApiProperty({
description: 'Type of scheduled task',
enum: TaskType,
example: 'restart',
})
@IsEnum(TaskType)
task_type: TaskType;
@ApiProperty({
description: 'Name of the task',
example: 'Daily restart',
})
@IsString()
task_name: string;
@ApiProperty({
description: 'Cron expression (e.g., "0 0 * * *" for daily at midnight)',
example: '0 0 * * *',
})
@IsString()
@Matches(/^(\*|([0-5]?\d)) (\*|([01]?\d|2[0-3])) (\*|([01]?\d|2\d|3[01])) (\*|([0]?\d|1[0-2])) (\*|[0-6])$/, {
message: 'Invalid cron expression format',
})
cron_expression: string;
@ApiProperty({
description: 'Timezone for the schedule (IANA timezone)',
example: 'America/New_York',
required: false,
})
@IsString()
@IsOptional()
timezone?: string;
@ApiProperty({
description: 'Task-specific configuration object',
example: { message: 'Server restarting in 5 minutes', countdown: 300 },
required: false,
})
@IsObject()
@IsOptional()
task_config?: Record<string, any>;
}

View File

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

View File

@@ -0,0 +1,103 @@
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 { SchedulesService } from './schedules.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@ApiTags('schedules')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('schedules')
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Get('tasks')
@ApiOperation({
summary: 'Get all scheduled tasks',
description: 'Returns all scheduled tasks for this license',
})
@ApiResponse({
status: 200,
description: 'Tasks retrieved successfully',
})
async getTasks(@CurrentTenant() licenseId: string) {
return await this.schedulesService.getTasks(licenseId);
}
@Post('tasks')
@ApiOperation({
summary: 'Create a scheduled task',
description: 'Create a new scheduled task (restart, announcement, command, or plugin reload)',
})
@ApiResponse({
status: 201,
description: 'Task created successfully',
})
@ApiResponse({
status: 400,
description: 'Invalid cron expression',
})
async createTask(
@CurrentTenant() licenseId: string,
@Body() dto: CreateTaskDto,
) {
return await this.schedulesService.createTask(licenseId, dto);
}
@Put('tasks/:id')
@ApiOperation({
summary: 'Update a scheduled task',
description: 'Update task configuration, schedule, or settings',
})
@ApiResponse({
status: 200,
description: 'Task updated successfully',
})
@ApiResponse({
status: 404,
description: 'Task not found',
})
async updateTask(
@CurrentTenant() licenseId: string,
@Param('id') taskId: string,
@Body() dto: UpdateTaskDto,
) {
return await this.schedulesService.updateTask(licenseId, taskId, dto);
}
@Delete('tasks/:id')
@ApiOperation({
summary: 'Delete a scheduled task',
description: 'Remove a scheduled task and unregister from scheduler',
})
@ApiResponse({
status: 200,
description: 'Task deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Task not found',
})
async deleteTask(
@CurrentTenant() licenseId: string,
@Param('id') taskId: string,
) {
return await this.schedulesService.deleteTask(licenseId, taskId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
@Module({
imports: [TypeOrmModule.forFeature([ScheduledTask])],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],
})
export class SchedulesModule {}

View File

@@ -0,0 +1,125 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@Injectable()
export class SchedulesService {
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
) {}
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
return await this.taskRepository.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
});
}
async createTask(
licenseId: string,
dto: CreateTaskDto,
): Promise<ScheduledTask> {
// Validate cron expression is parseable
// In production, you'd use a cron parser library to validate
// For now, we rely on the regex in the DTO
// Set default timezone if not provided
const timezone = dto.timezone || 'UTC';
const task = this.taskRepository.create({
license_id: licenseId,
task_type: dto.task_type,
task_name: dto.task_name,
cron_expression: dto.cron_expression,
timezone: timezone,
task_config: dto.task_config || {},
is_enabled: true,
last_run: null,
next_run: null, // Would be calculated by scheduler
created_at: new Date(),
});
const saved = await this.taskRepository.save(task);
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
// This would send a NATS message to the scheduler service to register the task
return saved;
}
async updateTask(
licenseId: string,
taskId: string,
dto: UpdateTaskDto,
): Promise<ScheduledTask> {
const task = await this.taskRepository.findOne({
where: {
id: taskId,
license_id: licenseId,
},
});
if (!task) {
throw new NotFoundException(`Scheduled task ${taskId} not found`);
}
// Update fields
Object.assign(task, dto);
const updated = await this.taskRepository.save(task);
// TODO: Update task registration with scheduler
// Send NATS message to update the task in tokio-cron-scheduler
return updated;
}
async deleteTask(licenseId: string, taskId: string) {
const task = await this.taskRepository.findOne({
where: {
id: taskId,
license_id: licenseId,
},
});
if (!task) {
throw new NotFoundException(`Scheduled task ${taskId} not found`);
}
await this.taskRepository.delete(taskId);
// TODO: Unregister task from scheduler
// Send NATS message to remove the task from tokio-cron-scheduler
return { deleted: true };
}
async toggleTask(licenseId: string, taskId: string, enabled: boolean) {
const task = await this.taskRepository.findOne({
where: {
id: taskId,
license_id: licenseId,
},
});
if (!task) {
throw new NotFoundException(`Scheduled task ${taskId} not found`);
}
task.is_enabled = enabled;
const updated = await this.taskRepository.save(task);
// TODO: Enable/disable task in scheduler
// Send NATS message to pause or resume the task
return updated;
}
}

View File

@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SendCommandDto {
@ApiProperty({
example: 'say "Hello, players!"',
description: 'Console command to execute on the server',
})
@IsString()
@IsNotEmpty()
command: string;
}

View File

@@ -0,0 +1,56 @@
import { IsOptional, IsString, IsInt, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateServerConfigDto {
@ApiPropertyOptional({ example: 'My Rust Server', description: 'Server name' })
@IsOptional()
@IsString()
server_name?: string;
@ApiPropertyOptional({ example: 100, description: 'Maximum players' })
@IsOptional()
@IsInt()
max_players?: number;
@ApiPropertyOptional({ example: 4000, description: 'World size' })
@IsOptional()
@IsInt()
world_size?: number;
@ApiPropertyOptional({ example: 123456, description: 'Current world seed' })
@IsOptional()
@IsInt()
current_seed?: number;
@ApiPropertyOptional({ example: true, description: 'Enable auto-restart' })
@IsOptional()
@IsBoolean()
auto_restart_enabled?: boolean;
@ApiPropertyOptional({ example: '0 4 * * *', description: 'Auto-restart cron schedule' })
@IsOptional()
@IsString()
auto_restart_cron?: string;
@ApiPropertyOptional({ example: true, description: 'Enable crash recovery' })
@IsOptional()
@IsBoolean()
crash_recovery_enabled?: boolean;
@ApiPropertyOptional({ example: true, description: 'Eligible for force wipes' })
@IsOptional()
@IsBoolean()
force_wipe_eligible?: boolean;
@ApiPropertyOptional({ example: true, description: 'Auto-update on force wipe' })
@IsOptional()
@IsBoolean()
auto_update_on_force_wipe?: boolean;
@ApiPropertyOptional({
example: { 'server.pve': 'true', 'server.radiation': 'false' },
description: 'Server config overrides (key-value pairs)',
})
@IsOptional()
config_overrides?: Record<string, string>;
}

View File

@@ -0,0 +1,65 @@
import { Controller, Get, Put, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ServersService } from './servers.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { SendCommandDto } from './dto/send-command.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('Servers')
@ApiBearerAuth()
@Controller('servers')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ServersController {
constructor(private readonly serversService: ServersService) {}
@Get()
@RequirePermission('server.view')
@ApiOperation({ summary: 'Get server connection and config' })
async getServer(@CurrentTenant() licenseId: string) {
return await this.serversService.getServer(licenseId);
}
@Put('config')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Update server configuration' })
async updateConfig(
@CurrentTenant() licenseId: string,
@Body() dto: UpdateServerConfigDto,
) {
return await this.serversService.updateConfig(licenseId, dto);
}
@Post('command')
@RequirePermission('server.console')
@ApiOperation({ summary: 'Send console command to server' })
async sendCommand(
@CurrentTenant() licenseId: string,
@Body() dto: SendCommandDto,
) {
return await this.serversService.sendCommand(licenseId, dto.command);
}
@Post('start')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Start the server' })
async startServer(@CurrentTenant() licenseId: string) {
return await this.serversService.startServer(licenseId);
}
@Post('stop')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Stop the server' })
async stopServer(@CurrentTenant() licenseId: string) {
return await this.serversService.stopServer(licenseId);
}
@Post('restart')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Restart the server' })
async restartServer(@CurrentTenant() licenseId: string) {
return await this.serversService.restartServer(licenseId);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ServersController } from './servers.controller';
import { ServersService } from './servers.service';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([ServerConnection, ServerConfig])],
controllers: [ServersController],
providers: [ServersService, NatsService],
exports: [ServersService],
})
export class ServersModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
@Injectable()
export class ServersService {
constructor(
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
@InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService,
) {}
/**
* Get server connection and config for a license
*/
async getServer(licenseId: string) {
const connection = await this.connectionRepo.findOne({
where: { license_id: licenseId },
});
const config = await this.configRepo.findOne({
where: { license_id: licenseId },
});
if (!connection || !config) {
throw new NotFoundException('Server not found for this license');
}
return { connection, config };
}
/**
* Update server configuration
*/
async updateConfig(licenseId: string, dto: UpdateServerConfigDto) {
const config = await this.configRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
throw new NotFoundException('Server config not found');
}
// Apply updates
Object.assign(config, dto);
config.updated_at = new Date();
return await this.configRepo.save(config);
}
/**
* Send a console command to the server via NATS
*/
async sendCommand(licenseId: string, command: string) {
await this.natsService.sendServerCommand(licenseId, 'command', { command });
return { output: 'Command sent' };
}
/**
* Start the server via NATS
*/
async startServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'start');
return { message: 'Start command sent' };
}
/**
* Stop the server via NATS
*/
async stopServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'stop');
return { message: 'Stop command sent' };
}
/**
* Restart the server via NATS
*/
async restartServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'restart');
return { message: 'Restart command sent' };
}
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateDomainDto {
@ApiProperty({
description: 'Subdomain (alphanumeric and hyphens only)',
example: 'myserver',
required: false,
})
@IsString()
@IsOptional()
@Matches(/^[a-z0-9-]+$/, {
message: 'Subdomain can only contain lowercase letters, numbers, and hyphens',
})
subdomain?: string;
@ApiProperty({
description: 'Custom domain',
example: 'play.myserver.com',
required: false,
})
@IsString()
@IsOptional()
custom_domain?: string;
}

View File

@@ -0,0 +1,122 @@
import { IsBoolean, IsString, IsUrl, IsOptional, IsObject } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdatePublicSiteDto {
@ApiProperty({
description: 'Enable public site',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
site_enabled?: boolean;
@ApiProperty({
description: 'Show server on status page',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
show_on_status_page?: boolean;
@ApiProperty({
description: 'Steam connect URL',
example: 'steam://connect/123.456.789.0:28015',
required: false,
})
@IsString()
@IsOptional()
steam_connect_url?: string;
@ApiProperty({
description: 'Message of the day',
example: 'Welcome to our server!',
required: false,
})
@IsString()
@IsOptional()
motd?: string;
@ApiProperty({
description: 'Public mods list',
example: ['Plugin1', 'Plugin2'],
required: false,
})
@IsOptional()
public_mods?: string[];
@ApiProperty({
description: 'Header image URL',
example: 'https://example.com/header.jpg',
required: false,
})
@IsUrl()
@IsString()
@IsOptional()
header_image_url?: string;
@ApiProperty({
description: 'Theme color (hex)',
example: '#1a1a1a',
required: false,
})
@IsString()
@IsOptional()
theme_color?: string;
@ApiProperty({
description: 'Discord invite URL',
example: 'https://discord.gg/xxxxx',
required: false,
})
@IsUrl()
@IsString()
@IsOptional()
discord_invite_url?: string;
@ApiProperty({
description: 'Show player count on public site',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
show_player_count?: boolean;
@ApiProperty({
description: 'Show wipe schedule on public site',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
show_wipe_schedule?: boolean;
@ApiProperty({
description: 'Show wipe countdown on public site',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
show_wipe_countdown?: boolean;
@ApiProperty({
description: 'Show mod list on public site',
example: true,
required: false,
})
@IsBoolean()
@IsOptional()
show_mod_list?: boolean;
@ApiProperty({
description: 'Status page description',
example: 'A friendly Rust server for all skill levels',
required: false,
})
@IsString()
@IsOptional()
status_page_description?: string;
}

View File

@@ -0,0 +1,73 @@
import { Controller, Get, Put, Body, 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 { SettingsService } from './settings.service';
import { UpdatePublicSiteDto } from './dto/update-public-site.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
@ApiTags('settings')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('public-site')
@ApiOperation({
summary: 'Get public site configuration',
description: 'Returns public site settings for this license',
})
@ApiResponse({
status: 200,
description: 'Public site config retrieved successfully',
})
async getPublicSite(@CurrentTenant() licenseId: string) {
return await this.settingsService.getPublicSite(licenseId);
}
@Put('public-site')
@ApiOperation({
summary: 'Update public site configuration',
description: 'Update public site settings for this license',
})
@ApiResponse({
status: 200,
description: 'Public site config updated successfully',
})
async updatePublicSite(
@CurrentTenant() licenseId: string,
@Body() dto: UpdatePublicSiteDto,
) {
return await this.settingsService.updatePublicSite(licenseId, dto);
}
@Put('domain')
@ApiOperation({
summary: 'Update domain settings',
description: 'Update subdomain or custom domain for this license',
})
@ApiResponse({
status: 200,
description: 'Domain settings updated successfully',
})
@ApiResponse({
status: 400,
description: 'Invalid domain format',
})
@ApiResponse({
status: 409,
description: 'Subdomain already taken',
})
async updateDomain(
@CurrentTenant() licenseId: string,
@Body() dto: UpdateDomainDto,
) {
return await this.settingsService.updateDomain(licenseId, dto);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { License } from '../../entities/license.entity';
@Module({
imports: [TypeOrmModule.forFeature([PublicSiteConfig, License])],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,145 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { License } from '../../entities/license.entity';
import { UpdatePublicSiteDto } from './dto/update-public-site.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
@Injectable()
export class SettingsService {
constructor(
@InjectRepository(PublicSiteConfig)
private publicSiteConfigRepository: Repository<PublicSiteConfig>,
@InjectRepository(License)
private licenseRepository: Repository<License>,
) {}
async getPublicSite(licenseId: string): Promise<PublicSiteConfig> {
let config = await this.publicSiteConfigRepository.findOne({
where: { license_id: licenseId },
});
// Create default config if not exists
if (!config) {
config = this.publicSiteConfigRepository.create({
license_id: licenseId,
site_enabled: false,
show_on_status_page: false,
steam_connect_url: null,
motd: null,
public_mods: [],
header_image_url: null,
theme_color: '#1a1a1a',
discord_invite_url: null,
show_player_count: true,
show_wipe_schedule: true,
show_wipe_countdown: true,
show_mod_list: true,
status_page_description: null,
});
config = await this.publicSiteConfigRepository.save(config);
}
return config;
}
async updatePublicSite(
licenseId: string,
dto: UpdatePublicSiteDto,
): Promise<PublicSiteConfig> {
// Ensure config exists first
let config = await this.getPublicSite(licenseId);
// Update fields
Object.assign(config, dto);
return await this.publicSiteConfigRepository.save(config);
}
async updateDomain(licenseId: string, dto: UpdateDomainDto) {
const license = await this.licenseRepository.findOne({
where: { id: licenseId },
});
if (!license) {
throw new NotFoundException(`License ${licenseId} not found`);
}
// Check if subdomain is already taken (if changing subdomain)
if (dto.subdomain && dto.subdomain !== license.subdomain) {
const existingSubdomain = await this.licenseRepository.findOne({
where: { subdomain: dto.subdomain },
});
if (existingSubdomain) {
throw new ConflictException(
`Subdomain "${dto.subdomain}" is already taken`,
);
}
// Validate subdomain format
if (dto.subdomain.length < 3 || dto.subdomain.length > 63) {
throw new BadRequestException(
'Subdomain must be between 3 and 63 characters',
);
}
if (dto.subdomain.startsWith('-') || dto.subdomain.endsWith('-')) {
throw new BadRequestException(
'Subdomain cannot start or end with a hyphen',
);
}
// TODO: Stub Cloudflare DNS provisioning
// In production, this would:
// 1. Create DNS CNAME record: {subdomain}.corrosionmgmt.com → panel.corrosionmgmt.com
// 2. Wait for DNS propagation
// 3. Verify SSL certificate provisioning
// For now, we just update the database
license.subdomain = dto.subdomain;
}
// Update custom domain if provided
if (dto.custom_domain !== undefined) {
if (dto.custom_domain && dto.custom_domain !== license.custom_domain) {
// Validate domain format (basic check)
const domainRegex = /^([a-z0-9-]+\.)+[a-z]{2,}$/i;
if (!domainRegex.test(dto.custom_domain)) {
throw new BadRequestException('Invalid custom domain format');
}
// TODO: Stub Cloudflare DNS verification
// In production, this would:
// 1. Instruct user to create CNAME pointing to panel.corrosionmgmt.com
// 2. Verify DNS record exists
// 3. Provision SSL certificate via Cloudflare
// 4. Mark domain as verified
// For now, we just update the database
license.custom_domain = dto.custom_domain;
} else if (dto.custom_domain === null || dto.custom_domain === '') {
// Allow clearing custom domain
license.custom_domain = null;
}
}
const updated = await this.licenseRepository.save(license);
return {
subdomain: updated.subdomain,
custom_domain: updated.custom_domain,
subdomain_url: updated.subdomain
? `https://${updated.subdomain}.corrosionmgmt.com`
: null,
custom_domain_url: updated.custom_domain
? `https://${updated.custom_domain}`
: null,
};
}
}

View File

@@ -0,0 +1,39 @@
import { IsString, IsOptional, IsInt, IsIn, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SetupServerDto {
@ApiProperty({ description: 'Connection type', enum: ['amp', 'pterodactyl', 'bare_metal'] })
@IsString()
@IsIn(['amp', 'pterodactyl', 'bare_metal'])
connection_type: 'amp' | 'pterodactyl' | 'bare_metal';
@ApiPropertyOptional({ description: 'Server IP address' })
@IsOptional()
@IsString()
server_ip?: string;
@ApiPropertyOptional({ description: 'Server RCON port' })
@IsOptional()
@IsInt()
server_port?: number;
@ApiPropertyOptional({ description: 'Game port (players connect to)' })
@IsOptional()
@IsInt()
game_port?: number;
@ApiPropertyOptional({ description: 'Panel API endpoint (for AMP/Pterodactyl)' })
@ValidateIf(o => o.connection_type === 'amp' || o.connection_type === 'pterodactyl')
@IsString()
panel_api_endpoint?: string;
@ApiPropertyOptional({ description: 'Panel API key (for AMP/Pterodactyl)' })
@ValidateIf(o => o.connection_type === 'amp' || o.connection_type === 'pterodactyl')
@IsString()
panel_api_key?: string;
@ApiPropertyOptional({ description: 'Panel server identifier (for AMP/Pterodactyl)' })
@ValidateIf(o => o.connection_type === 'amp' || o.connection_type === 'pterodactyl')
@IsString()
panel_server_identifier?: string;
}

View File

@@ -0,0 +1,27 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { SetupService } from './setup.service';
import { SetupServerDto } from './dto/setup-server.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
@ApiTags('setup')
@ApiBearerAuth()
@Controller('setup')
export class SetupController {
constructor(private readonly setupService: SetupService) {}
@Post('server')
@ApiOperation({ summary: 'Configure server connection during setup' })
async setupServer(
@CurrentTenant() licenseId: string,
@Body() dto: SetupServerDto,
) {
return this.setupService.setupServer(licenseId, dto);
}
@Post('complete')
@ApiOperation({ summary: 'Mark setup as complete' })
async completeSetup(@CurrentTenant() licenseId: string) {
return this.setupService.completeSetup(licenseId);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SetupController } from './setup.controller';
import { SetupService } from './setup.service';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NotificationsConfig } from '../../entities/notifications-config.entity';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { AlertConfig } from '../../entities/alert-config.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ServerConnection,
ServerConfig,
NotificationsConfig,
PublicSiteConfig,
AlertConfig,
]),
],
controllers: [SetupController],
providers: [SetupService],
exports: [SetupService],
})
export class SetupModule {}

View File

@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NotificationsConfig } from '../../entities/notifications-config.entity';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { AlertConfig } from '../../entities/alert-config.entity';
import { SetupServerDto } from './dto/setup-server.dto';
import * as crypto from 'crypto';
@Injectable()
export class SetupService {
constructor(
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
@InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>,
@InjectRepository(NotificationsConfig)
private readonly notifConfigRepo: Repository<NotificationsConfig>,
@InjectRepository(PublicSiteConfig)
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
@InjectRepository(AlertConfig)
private readonly alertConfigRepo: Repository<AlertConfig>,
) {}
async setupServer(licenseId: string, dto: SetupServerDto): Promise<ServerConnection> {
// Check if connection already exists
let connection = await this.connectionRepo.findOne({
where: { license_id: licenseId },
});
if (!connection) {
connection = this.connectionRepo.create({
license_id: licenseId,
});
}
// Update connection details
connection.connection_type = dto.connection_type;
connection.server_ip = dto.server_ip || null;
connection.server_port = dto.server_port || null;
connection.game_port = dto.game_port || null;
connection.panel_api_endpoint = dto.panel_api_endpoint || null;
connection.panel_server_identifier = dto.panel_server_identifier || null;
// For bare metal, generate companion agent token
if (dto.connection_type === 'bare_metal') {
connection.companion_agent_token = crypto.randomBytes(32).toString('hex');
}
// Store encrypted API key if provided
if (dto.panel_api_key) {
// Stub - would encrypt in production
connection.panel_api_key_encrypted = dto.panel_api_key;
}
connection.updated_at = new Date();
const savedConnection = await this.connectionRepo.save(connection);
// Create default configurations if they don't exist
await this.createDefaultConfigs(licenseId);
return savedConnection;
}
async completeSetup(licenseId: string): Promise<{ message: string }> {
const connection = await this.connectionRepo.findOne({
where: { license_id: licenseId },
});
if (connection) {
// For bare metal, mark as connected immediately (waiting for agent)
if (connection.connection_type === 'bare_metal') {
connection.connection_status = 'connected';
connection.updated_at = new Date();
await this.connectionRepo.save(connection);
}
}
return { message: 'Setup complete' };
}
private async createDefaultConfigs(licenseId: string): Promise<void> {
// Create server config if not exists
const existingConfig = await this.configRepo.findOne({
where: { license_id: licenseId },
});
if (!existingConfig) {
const config = this.configRepo.create({
license_id: licenseId,
server_name: 'My Rust Server',
});
await this.configRepo.save(config);
}
// Create notifications config if not exists
const existingNotif = await this.notifConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!existingNotif) {
const notifConfig = this.notifConfigRepo.create({
license_id: licenseId,
});
await this.notifConfigRepo.save(notifConfig);
}
// Create public site config if not exists
const existingPublic = await this.publicSiteRepo.findOne({
where: { license_id: licenseId },
});
if (!existingPublic) {
const publicConfig = this.publicSiteRepo.create({
license_id: licenseId,
});
await this.publicSiteRepo.save(publicConfig);
}
// Create alert config if not exists
const existingAlert = await this.alertConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!existingAlert) {
const alertConfig = this.alertConfigRepo.create({
license_id: licenseId,
});
await this.alertConfigRepo.save(alertConfig);
}
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { StatusService } from './status.service';
import { Public } from '../../common/decorators/public.decorator';
@ApiTags('public')
@Controller('public/status')
export class StatusController {
constructor(private readonly statusService: StatusService) {}
@Public()
@Get()
@ApiOperation({ summary: 'Get public server status page data' })
async getStatus() {
return this.statusService.getStatus();
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StatusController } from './status.controller';
import { StatusService } from './status.service';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { License } from '../../entities/license.entity';
@Module({
imports: [
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
],
controllers: [StatusController],
providers: [StatusService],
exports: [StatusService],
})
export class StatusModule {}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { License } from '../../entities/license.entity';
@Injectable()
export class StatusService {
constructor(
@InjectRepository(PublicSiteConfig)
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
@InjectRepository(ServerConnection)
private readonly serverConnectionRepo: Repository<ServerConnection>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
) {}
async getStatus() {
const publicConfigs = await this.publicSiteRepo.find({
where: { show_on_status_page: true },
relations: ['license'],
});
const servers = await Promise.all(
publicConfigs.map(async (config) => {
const license = await this.licenseRepo.findOne({
where: { id: config.license_id },
});
const connection = await this.serverConnectionRepo.findOne({
where: { license_id: config.license_id },
});
return {
server_name: license?.subdomain || 'Unknown Server',
subdomain: license?.subdomain || null,
status: connection?.connection_status || 'offline',
player_count: 0, // Would need real-time data
max_players: 0,
steam_connect_url: config.steam_connect_url,
motd: config.motd,
discord_invite_url: config.discord_invite_url,
theme_color: config.theme_color,
description: config.status_page_description,
};
}),
);
return { servers };
}
}

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { StoreService } from './store.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
@ApiTags('modules')
@ApiBearerAuth()
@Controller('modules')
export class StoreController {
constructor(private readonly storeService: StoreService) {}
@Get('catalog')
@ApiOperation({ summary: 'Get module marketplace catalog' })
async getCatalog() {
return this.storeService.getCatalog();
}
@Get('my-modules')
@ApiOperation({ summary: 'Get purchased and installed modules for current license' })
async getMyModules(@CurrentTenant() licenseId: string) {
return this.storeService.getMyModules(licenseId);
}
@Post('purchase')
@ApiOperation({ summary: 'Purchase a module' })
async purchaseModule(
@CurrentTenant() licenseId: string,
@Body('module_id') moduleId: string,
) {
return this.storeService.purchaseModule(licenseId, moduleId);
}
@Post('install')
@ApiOperation({ summary: 'Install a purchased module' })
async installModule(
@CurrentTenant() licenseId: string,
@Body('module_id') moduleId: string,
) {
return this.storeService.installModule(licenseId, moduleId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StoreController } from './store.controller';
import { StoreService } from './store.service';
import { Module as ModuleEntity } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
@Module({
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
controllers: [StoreController],
providers: [StoreService],
exports: [StoreService],
})
export class StoreModule {}

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Module } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
@Injectable()
export class StoreService {
constructor(
@InjectRepository(Module)
private readonly moduleRepo: Repository<Module>,
@InjectRepository(ModulePurchase)
private readonly purchaseRepo: Repository<ModulePurchase>,
) {}
async getCatalog(): Promise<Module[]> {
return this.moduleRepo.find({
order: { created_at: 'DESC' },
});
}
async getMyModules(licenseId: string) {
const purchases = await this.purchaseRepo.find({
where: { license_id: licenseId },
relations: ['module'],
order: { purchased_at: 'DESC' },
});
return {
purchased: purchases,
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
};
}
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
// Check if already purchased
const existing = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
if (existing) {
return existing;
}
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
if (!module) {
throw new Error('Module not found');
}
const purchase = this.purchaseRepo.create({
license_id: licenseId,
module_id: moduleId,
transaction_id: `txn_${Date.now()}`, // Stub
amount_paid: parseFloat(module.price_usd.toString()),
});
return this.purchaseRepo.save(purchase);
}
async installModule(licenseId: string, moduleId: string) {
// Verify purchase exists
const purchase = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
if (!purchase) {
throw new Error('Module not purchased');
}
// Stub - would create module_installation record
return {
message: 'Module installed successfully',
module_id: moduleId,
status: 'installed',
};
}
}

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

View File

@@ -0,0 +1,29 @@
import { IsString, IsOptional, IsInt, IsBoolean, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCategoryDto {
@ApiProperty({ description: 'Category name' })
@IsString()
@Length(1, 100)
name: string;
@ApiProperty({ description: 'URL-friendly slug' })
@IsString()
@Length(1, 100)
slug: string;
@ApiPropertyOptional({ description: 'Category description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Display order (lower = first)' })
@IsOptional()
@IsInt()
display_order?: number;
@ApiPropertyOptional({ description: 'Is category visible' })
@IsOptional()
@IsBoolean()
visible?: boolean;
}

View File

@@ -0,0 +1,49 @@
import { IsString, IsOptional, IsNumber, IsInt, IsBoolean, IsArray, IsUUID, Length, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateItemDto {
@ApiPropertyOptional({ description: 'Category ID' })
@IsOptional()
@IsUUID()
category_id?: string;
@ApiProperty({ description: 'Item name' })
@IsString()
@Length(1, 200)
name: string;
@ApiPropertyOptional({ description: 'Item description' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ description: 'Price in configured currency' })
@IsNumber()
@Min(0)
price: number;
@ApiPropertyOptional({ description: 'Image URL' })
@IsOptional()
@IsString()
image_url?: string;
@ApiProperty({ description: 'Item type (kit, rank, currency, command)' })
@IsString()
item_type: string;
@ApiProperty({ description: 'Console commands to execute on purchase', type: [String] })
@IsArray()
@IsString({ each: true })
delivery_commands: string[];
@ApiPropertyOptional({ description: 'Purchase limit per player (null = unlimited)' })
@IsOptional()
@IsInt()
@Min(1)
limit_per_player?: number;
@ApiPropertyOptional({ description: 'Is item enabled for sale' })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsUUID, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PurchaseDto {
@ApiProperty({ description: 'Store item ID' })
@IsUUID()
item_id: string;
@ApiProperty({ description: 'Player Steam ID' })
@IsString()
@Length(1, 20)
steam_id: string;
@ApiProperty({ description: 'Player display name' })
@IsString()
@Length(1, 100)
player_name: string;
}

View File

@@ -0,0 +1,41 @@
import { IsString, IsOptional, IsBoolean, Length } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateStoreConfigDto {
@ApiPropertyOptional({ description: 'Store display name' })
@IsOptional()
@IsString()
@Length(1, 200)
store_name?: string;
@ApiPropertyOptional({ description: 'Store description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Currency code (e.g., USD)' })
@IsOptional()
@IsString()
@Length(3, 3)
currency?: string;
@ApiPropertyOptional({ description: 'PayPal client ID' })
@IsOptional()
@IsString()
paypal_client_id?: string;
@ApiPropertyOptional({ description: 'PayPal client secret' })
@IsOptional()
@IsString()
paypal_client_secret?: string;
@ApiPropertyOptional({ description: 'Use PayPal sandbox mode' })
@IsOptional()
@IsBoolean()
sandbox_mode?: boolean;
@ApiPropertyOptional({ description: 'Enable the webstore' })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,150 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger';
import { WebstoreService } from './webstore.service';
import { UpdateStoreConfigDto } from './dto/update-store-config.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { CreateItemDto } from './dto/create-item.dto';
import { PurchaseDto } from './dto/purchase.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { Public } from '../../common/decorators/public.decorator';
@ApiTags('webstore')
@Controller()
export class WebstoreController {
constructor(private readonly webstoreService: WebstoreService) {}
// Admin Routes
@Get('webstore/config')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get webstore configuration' })
async getConfig(@CurrentTenant() licenseId: string) {
return this.webstoreService.getConfig(licenseId);
}
@Put('webstore/config')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update webstore configuration' })
async updateConfig(
@CurrentTenant() licenseId: string,
@Body() dto: UpdateStoreConfigDto,
) {
return this.webstoreService.updateConfig(licenseId, dto);
}
@Get('webstore/categories')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store categories' })
async getCategories(@CurrentTenant() licenseId: string) {
return this.webstoreService.getCategories(licenseId);
}
@Post('webstore/categories')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new category' })
async createCategory(
@CurrentTenant() licenseId: string,
@Body() dto: CreateCategoryDto,
) {
return this.webstoreService.createCategory(licenseId, dto);
}
@Put('webstore/categories/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a category' })
@ApiParam({ name: 'id', description: 'Category ID' })
async updateCategory(
@CurrentTenant() licenseId: string,
@Param('id') categoryId: string,
@Body() dto: Partial<CreateCategoryDto>,
) {
return this.webstoreService.updateCategory(licenseId, categoryId, dto);
}
@Delete('webstore/categories/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a category' })
@ApiParam({ name: 'id', description: 'Category ID' })
async deleteCategory(
@CurrentTenant() licenseId: string,
@Param('id') categoryId: string,
) {
await this.webstoreService.deleteCategory(licenseId, categoryId);
return { message: 'Category deleted successfully' };
}
@Get('webstore/items')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store items' })
async getItems(@CurrentTenant() licenseId: string) {
return this.webstoreService.getItems(licenseId);
}
@Post('webstore/items')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new store item' })
async createItem(
@CurrentTenant() licenseId: string,
@Body() dto: CreateItemDto,
) {
return this.webstoreService.createItem(licenseId, dto);
}
@Put('webstore/items/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a store item' })
@ApiParam({ name: 'id', description: 'Item ID' })
async updateItem(
@CurrentTenant() licenseId: string,
@Param('id') itemId: string,
@Body() dto: Partial<CreateItemDto>,
) {
return this.webstoreService.updateItem(licenseId, itemId, dto);
}
@Delete('webstore/items/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a store item' })
@ApiParam({ name: 'id', description: 'Item ID' })
async deleteItem(
@CurrentTenant() licenseId: string,
@Param('id') itemId: string,
) {
await this.webstoreService.deleteItem(licenseId, itemId);
return { message: 'Item deleted successfully' };
}
@Get('webstore/transactions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store transactions' })
async getTransactions(@CurrentTenant() licenseId: string) {
return this.webstoreService.getTransactions(licenseId);
}
// Public Routes
@Public()
@Get('public-store/:subdomain')
@ApiOperation({ summary: 'Get public store information' })
@ApiParam({ name: 'subdomain', description: 'Server subdomain' })
async getPublicStore(@Param('subdomain') subdomain: string) {
return this.webstoreService.getPublicStore(subdomain);
}
@Public()
@Get('public-store/:subdomain/items')
@ApiOperation({ summary: 'Get public store items' })
@ApiParam({ name: 'subdomain', description: 'Server subdomain' })
async getPublicItems(@Param('subdomain') subdomain: string) {
return this.webstoreService.getPublicItems(subdomain);
}
@Public()
@Post('public-store/:subdomain/purchase')
@ApiOperation({ summary: 'Create a purchase order' })
@ApiParam({ name: 'subdomain', description: 'Server subdomain' })
async createPurchase(
@Param('subdomain') subdomain: string,
@Body() dto: PurchaseDto,
) {
return this.webstoreService.createPurchase(subdomain, dto);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WebstoreController } from './webstore.controller';
import { WebstoreService } from './webstore.service';
import { StoreConfig } from '../../entities/store-config.entity';
import { StoreCategory } from '../../entities/store-category.entity';
import { StoreItem } from '../../entities/store-item.entity';
import { StoreTransaction } from '../../entities/store-transaction.entity';
import { License } from '../../entities/license.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
StoreConfig,
StoreCategory,
StoreItem,
StoreTransaction,
License,
]),
],
controllers: [WebstoreController],
providers: [WebstoreService],
exports: [WebstoreService],
})
export class WebstoreModule {}

View File

@@ -0,0 +1,246 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StoreConfig } from '../../entities/store-config.entity';
import { StoreCategory } from '../../entities/store-category.entity';
import { StoreItem } from '../../entities/store-item.entity';
import { StoreTransaction } from '../../entities/store-transaction.entity';
import { License } from '../../entities/license.entity';
import { UpdateStoreConfigDto } from './dto/update-store-config.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { CreateItemDto } from './dto/create-item.dto';
import { PurchaseDto } from './dto/purchase.dto';
@Injectable()
export class WebstoreService {
constructor(
@InjectRepository(StoreConfig)
private readonly configRepo: Repository<StoreConfig>,
@InjectRepository(StoreCategory)
private readonly categoryRepo: Repository<StoreCategory>,
@InjectRepository(StoreItem)
private readonly itemRepo: Repository<StoreItem>,
@InjectRepository(StoreTransaction)
private readonly transactionRepo: Repository<StoreTransaction>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
) {}
// Admin Methods
async getConfig(licenseId: string): Promise<StoreConfig> {
let config = await this.configRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
// Create default config
config = this.configRepo.create({
license_id: licenseId,
store_name: 'My Store',
enabled: false,
sandbox_mode: true,
});
await this.configRepo.save(config);
}
return config;
}
async updateConfig(licenseId: string, dto: UpdateStoreConfigDto): Promise<StoreConfig> {
let config = await this.configRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
config = this.configRepo.create({
license_id: licenseId,
...dto,
});
} else {
Object.assign(config, dto);
config.updated_at = new Date();
}
return this.configRepo.save(config);
}
async getCategories(licenseId: string): Promise<StoreCategory[]> {
return this.categoryRepo.find({
where: { license_id: licenseId },
order: { display_order: 'ASC', name: 'ASC' },
});
}
async createCategory(licenseId: string, dto: CreateCategoryDto): Promise<StoreCategory> {
const category = this.categoryRepo.create({
license_id: licenseId,
...dto,
});
return this.categoryRepo.save(category);
}
async updateCategory(licenseId: string, categoryId: string, dto: Partial<CreateCategoryDto>): Promise<StoreCategory> {
const category = await this.categoryRepo.findOne({
where: { id: categoryId, license_id: licenseId },
});
if (!category) {
throw new NotFoundException('Category not found');
}
Object.assign(category, dto);
return this.categoryRepo.save(category);
}
async deleteCategory(licenseId: string, categoryId: string): Promise<void> {
const result = await this.categoryRepo.delete({
id: categoryId,
license_id: licenseId,
});
if (result.affected === 0) {
throw new NotFoundException('Category not found');
}
}
async getItems(licenseId: string): Promise<StoreItem[]> {
return this.itemRepo.find({
where: { license_id: licenseId },
relations: ['category'],
order: { created_at: 'DESC' },
});
}
async createItem(licenseId: string, dto: CreateItemDto): Promise<StoreItem> {
const item = this.itemRepo.create({
license_id: licenseId,
...dto,
});
return this.itemRepo.save(item);
}
async updateItem(licenseId: string, itemId: string, dto: Partial<CreateItemDto>): Promise<StoreItem> {
const item = await this.itemRepo.findOne({
where: { id: itemId, license_id: licenseId },
});
if (!item) {
throw new NotFoundException('Item not found');
}
Object.assign(item, dto);
item.updated_at = new Date();
return this.itemRepo.save(item);
}
async deleteItem(licenseId: string, itemId: string): Promise<void> {
const result = await this.itemRepo.delete({
id: itemId,
license_id: licenseId,
});
if (result.affected === 0) {
throw new NotFoundException('Item not found');
}
}
async getTransactions(licenseId: string): Promise<StoreTransaction[]> {
return this.transactionRepo.find({
where: { license_id: licenseId },
relations: ['item'],
order: { created_at: 'DESC' },
});
}
// Public Methods
async getPublicStore(subdomain: string) {
const license = await this.licenseRepo.findOne({
where: { subdomain },
});
if (!license) {
throw new NotFoundException('Store not found');
}
const config = await this.configRepo.findOne({
where: { license_id: license.id },
});
if (!config || !config.enabled) {
throw new NotFoundException('Store not available');
}
return {
store_name: config.store_name,
description: config.description,
currency: config.currency,
};
}
async getPublicItems(subdomain: string) {
const license = await this.licenseRepo.findOne({
where: { subdomain },
});
if (!license) {
throw new NotFoundException('Store not found');
}
const items = await this.itemRepo
.createQueryBuilder('item')
.leftJoinAndSelect('item.category', 'category')
.where('item.license_id = :licenseId', { licenseId: license.id })
.andWhere('item.enabled = true')
.andWhere('(category.visible = true OR item.category_id IS NULL)')
.orderBy('category.display_order', 'ASC')
.addOrderBy('item.name', 'ASC')
.getMany();
return items.map(item => ({
id: item.id,
name: item.name,
description: item.description,
price: item.price,
image_url: item.image_url,
item_type: item.item_type,
category_name: item.category?.name || null,
}));
}
async createPurchase(subdomain: string, dto: PurchaseDto) {
const license = await this.licenseRepo.findOne({
where: { subdomain },
});
if (!license) {
throw new NotFoundException('Store not found');
}
const item = await this.itemRepo.findOne({
where: { id: dto.item_id, license_id: license.id, enabled: true },
});
if (!item) {
throw new NotFoundException('Item not found');
}
const transaction = this.transactionRepo.create({
license_id: license.id,
item_id: item.id,
steam_id: dto.steam_id,
player_name: dto.player_name,
paypal_order_id: `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
amount: parseFloat(item.price.toString()),
currency: 'USD', // Would get from config
status: 'pending',
});
await this.transactionRepo.save(transaction);
// Return mock PayPal approval URL
return {
order_id: transaction.paypal_order_id,
approval_url: `https://www.sandbox.paypal.com/checkoutnow?token=${transaction.paypal_order_id}`,
};
}
}

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