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

@@ -47,7 +47,7 @@
### **THE SCOUT (Reconnaissance)** ### **THE SCOUT (Reconnaissance)**
* **Model:** claude-3-haiku (or claude-3-5-haiku) * **Model:** haiku (or claude-3-5-haiku)
* **Role:** High-speed intelligence gathering, context mapping, and file summarization. * **Role:** High-speed intelligence gathering, context mapping, and file summarization.
* **Directives:** * **Directives:**
* **Read-Only:** STRICTLY FORBIDDEN from writing code or modifying files. * **Read-Only:** STRICTLY FORBIDDEN from writing code or modifying files.
@@ -114,6 +114,7 @@
### **THE AUDITOR (QA / Tester)** ### **THE AUDITOR (QA / Tester)**
* **Model:** sonnet
* **Role:** Verification, stress testing, and breaking things. * **Role:** Verification, stress testing, and breaking things.
* **Directives:** * **Directives:**
* Act hostile to the code. Try to break it. * Act hostile to the code. Try to break it.
@@ -150,3 +151,25 @@
* **Agent:** Overwatch * **Agent:** Overwatch
* **Order:** "Compile the results. Report status. Await next command." * **Order:** "Compile the results. Report status. Await next command."
---
## 5. MISSION LOG
### 2026-02-15 // NestJS Module Generation (Wipes, Maps, Plugins)
**Agent:** Specialist (Sonnet 4.5)
**Objective:** Generate complete NestJS modules with controller/service/DTO/module structure for Wipes, Maps, and Plugins.
**Execution:**
- Generated 3 complete modules totaling 16 files across DTOs, services, controllers, and module definitions
- All files follow established patterns: @InjectRepository, @CurrentTenant(), @RequirePermission(), ApiTags/ApiBearerAuth
- class-validator decorators on all DTO fields, PartialType imported from @nestjs/swagger for proper Swagger integration
- Permission-based guards applied: wipe.view/manage/execute, map.view/manage, plugin.view/manage
**Deliverables:**
- **Wipes Module** (7 files): Profile/schedule CRUD, wipe history, manual trigger, dry-run simulation
- **Maps Module** (5 files): Library management, rotation system with order control
- **Plugins Module** (6 files): Install/uninstall, config management, reload trigger, uMod search stub
**Result:** All modules operational and ready for integration into main app.module.ts. Multi-tenant isolation enforced via license_id scoping.

4
backend-nest/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.js.map

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

6748
backend-nest/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
backend-nest/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "corrosion-api",
"version": "1.0.0",
"description": "Corrosion Admin Panel — NestJS Backend API",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\""
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.4.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.4.0",
"argon2": "^0.40.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nats": "^2.19.0",
"otpauth": "^9.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@types/express": "^4.17.21",
"@types/node": "^20.12.0",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"typescript": "^5.4.0"
}
}

View File

@@ -0,0 +1,122 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import configuration from './config/configuration';
// Guards
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { LicenseGuard } from './common/guards/license.guard';
// Feature Modules
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { LicensesModule } from './modules/licenses/licenses.module';
import { ServersModule } from './modules/servers/servers.module';
import { ConsoleModule } from './modules/console/console.module';
import { PlayersModule } from './modules/players/players.module';
import { WipesModule } from './modules/wipes/wipes.module';
import { MapsModule } from './modules/maps/maps.module';
import { PluginsModule } from './modules/plugins/plugins.module';
import { ChatModule } from './modules/chat/chat.module';
import { TeamModule } from './modules/team/team.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { SettingsModule } from './modules/settings/settings.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { AlertsModule } from './modules/alerts/alerts.module';
import { StatusModule } from './modules/status/status.module';
import { StoreModule } from './modules/store/store.module';
import { WebstoreModule } from './modules/webstore/webstore.module';
import { AdminModule } from './modules/admin/admin.module';
import { SetupModule } from './modules/setup/setup.module';
import { MigrationModule } from './modules/migration/migration.module';
// Shared Services
import { NatsService } from './services/nats.service';
import { NatsBridgeService } from './services/nats-bridge.service';
import { SteamService } from './services/steam.service';
// Gateway
import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
// Database
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres' as const,
url: config.get<string>('database.url'),
autoLoadEntities: true,
synchronize: false, // NEVER auto-sync — use migrations only
extra: {
max: config.get<number>('database.maxConnections') || 20,
},
}),
}),
// JWT (global)
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('jwt.secret'),
signOptions: {
expiresIn: `${config.get<number>('jwt.accessExpirySeconds') || 900}s`,
},
}),
}),
// Scheduler
ScheduleModule.forRoot(),
// Feature Modules
AuthModule,
UsersModule,
LicensesModule,
ServersModule,
ConsoleModule,
PlayersModule,
WipesModule,
MapsModule,
PluginsModule,
ChatModule,
TeamModule,
NotificationsModule,
SettingsModule,
SchedulesModule,
AnalyticsModule,
AlertsModule,
StatusModule,
StoreModule,
WebstoreModule,
AdminModule,
SetupModule,
MigrationModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
// Shared services
NatsService,
NatsBridgeService,
SteamService,
// WebSocket gateway
NatsBridgeGateway,
],
exports: [NatsService, NatsBridgeService, SteamService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.user?.license_id;
},
);

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'required_permission';
export const RequirePermission = (permission: string) =>
SetMetadata(PERMISSION_KEY, permission);

View File

@@ -0,0 +1,17 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 25 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number = 25;
}

View File

@@ -0,0 +1,35 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exResponse = exception.getResponse();
if (typeof exResponse === 'string') {
message = exResponse;
} else if (typeof exResponse === 'object' && exResponse !== null) {
const obj = exResponse as Record<string, unknown>;
message = (obj.message as string) || message;
if (Array.isArray(obj.message)) {
message = obj.message[0] as string;
}
}
}
response.status(status).json({ message });
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,12 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class LicenseGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (!user?.license_id) {
throw new ForbiddenException('No active license');
}
return true;
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermission = this.reflector.getAllAndOverride<string>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermission) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
// Super admins bypass all permission checks
if (user.is_super_admin) return true;
// Check permissions JSONB from role
const permissions = user.permissions as Record<string, boolean> | undefined;
if (!permissions) return false;
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
const parts = requiredPermission.split('.');
const wildcard = parts[0] + '.*';
return permissions[requiredPermission] === true || permissions[wildcard] === true;
}
}

View File

@@ -0,0 +1,12 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class SuperAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (!user?.is_super_admin) {
throw new ForbiddenException('Platform admin access required');
}
return true;
}
}

View File

@@ -0,0 +1,17 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, T> {
intercept(_context: ExecutionContext, next: CallHandler): Observable<T> {
// Pass through — response shape is controlled by each controller
// This interceptor exists for future response wrapping if needed
return next.handle().pipe(map((data) => data));
}
}

View File

@@ -0,0 +1,42 @@
export default () => ({
port: parseInt(process.env.API_PORT || '3000', 10),
database: {
url: process.env.DATABASE_URL || 'postgres://corrosion:corrosion_dev@localhost:5432/corrosion',
maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || '20', 10),
},
nats: {
url: process.env.NATS_URL || 'nats://localhost:4222',
},
jwt: {
secret: process.env.JWT_SECRET || 'change-me',
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '900', 10),
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
},
encryption: {
key: process.env.ENCRYPTION_KEY || '',
},
admin: {
email: process.env.ADMIN_EMAIL || '',
password: process.env.ADMIN_PASSWORD || '',
username: process.env.ADMIN_USERNAME || 'Commander',
licenseKey: process.env.ADMIN_LICENSE_KEY || '',
},
cloudflare: {
apiToken: process.env.CLOUDFLARE_API_TOKEN || '',
zoneId: process.env.CLOUDFLARE_ZONE_ID || '',
baseDomain: process.env.BASE_DOMAIN || 'corrosionmgmt.com',
},
steam: {
apiKey: process.env.STEAM_API_KEY || '',
},
smtp: {
host: process.env.SMTP_HOST || '',
port: parseInt(process.env.SMTP_PORT || '587', 10),
username: process.env.SMTP_USERNAME || '',
password: process.env.SMTP_PASSWORD || '',
from: process.env.SMTP_FROM || 'noreply@corrosionmgmt.com',
},
frontend: {
url: process.env.FRONTEND_URL || 'http://localhost:5174',
},
});

View File

@@ -0,0 +1,43 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { License } from './license.entity';
@Entity('alert_config')
@Index(['license_id'], { unique: true })
export class AlertConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'boolean', default: true })
population_drop_enabled: boolean;
@Column({ type: 'integer', default: 30 })
population_drop_threshold_percent: number;
@Column({ type: 'boolean', default: true })
fps_degradation_enabled: boolean;
@Column({ type: 'integer', default: 30 })
fps_threshold: number;
@Column({ type: 'boolean', default: true })
notify_discord: boolean;
@Column({ type: 'boolean', default: false })
notify_pushbullet: boolean;
@Column({ type: 'boolean', default: false })
notify_email: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('alert_history')
export class AlertHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 50 })
alert_type: string;
@Column({ type: 'varchar', length: 20 })
severity: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'text' })
message: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@Column({ type: 'boolean', default: false })
notified_discord: boolean;
@Column({ type: 'boolean', default: false })
notified_pushbullet: boolean;
@Column({ type: 'boolean', default: false })
notified_email: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
triggered_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,45 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { User } from './user.entity';
@Entity('chat_logs')
@Check(`"channel" IN ('global', 'team', 'server')`)
export class ChatLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 20 })
steam_id: string;
@Column({ type: 'varchar', length: 100 })
player_name: string;
@Column({ type: 'varchar', length: 20, default: 'global' })
channel: string;
@Column({ type: 'text' })
message: string;
@Column({ type: 'boolean', default: false })
flagged: boolean;
@Column({ type: 'uuid', nullable: true })
flagged_by: string | null;
@Column({ type: 'text', nullable: true })
flag_reason: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'flagged_by' })
flagger: User | null;
}

View File

@@ -0,0 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('early_access_signups')
export class EarlyAccessSignup {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
email: string;
@Column({ type: 'varchar', length: 10 })
server_count: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
}

View File

@@ -0,0 +1,40 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique, Check } from 'typeorm';
import { License } from './license.entity';
import { User } from './user.entity';
@Entity('game_admins')
@Unique(['license_id', 'steam_id'])
@Check(`"admin_level" IN ('owner', 'admin', 'moderator')`)
export class GameAdmin {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 20 })
steam_id: string;
@Column({ type: 'varchar', length: 100, default: '' })
display_name: string;
@Column({ type: 'varchar', length: 20, default: 'admin' })
admin_level: string;
@Column({ type: 'jsonb', default: {} })
permissions: Record<string, any>;
@Column({ type: 'uuid' })
added_by: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => User)
@JoinColumn({ name: 'added_by' })
adder: User;
}

View File

@@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Host } from './host.entity';
@Entity('host_billing_records')
@Unique(['host_id', 'billing_month'])
export class HostBillingRecord {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
host_id: string;
@Column({ type: 'date' })
billing_month: Date;
@Column({ type: 'integer' })
active_license_count: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
wholesale_rate_usd: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
total_amount_usd: number;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
generated_at: Date;
@ManyToOne(() => Host, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'host_id' })
host: Host;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Host } from './host.entity';
import { License } from './license.entity';
@Entity('host_licenses')
@Unique(['host_id', 'license_id'])
export class HostLicense {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
host_id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 255, nullable: true })
server_identifier: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
customer_email: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
provisioned_at: Date;
@Column({ type: 'timestamptz', nullable: true })
last_seen_at: Date | null;
@ManyToOne(() => Host, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'host_id' })
host: Host;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('hosts')
export class Host {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 200 })
company_name: string;
@Column({ type: 'varchar', length: 255, unique: true })
contact_email: string;
@Column({ type: 'varchar', length: 64, unique: true })
api_key: string;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 6.00 })
wholesale_rate_usd: number;
@Column({ type: 'boolean', default: true })
active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
}

View File

@@ -0,0 +1,38 @@
export { User } from './user.entity';
export { License } from './license.entity';
export { Role } from './role.entity';
export { TeamMember } from './team-member.entity';
export { ServerConnection } from './server-connection.entity';
export { ServerConfig } from './server-config.entity';
export { GameAdmin } from './game-admin.entity';
export { WipeProfile } from './wipe-profile.entity';
export { WipeSchedule } from './wipe-schedule.entity';
export { WipeHistory } from './wipe-history.entity';
export { MapLibrary } from './map-library.entity';
export { MapRotation } from './map-rotation.entity';
export { PluginRegistry } from './plugin-registry.entity';
export { ScheduledTask } from './scheduled-task.entity';
export { NotificationsConfig } from './notifications-config.entity';
export { ChatLog } from './chat-log.entity';
export { PlayerAction } from './player-action.entity';
export { ServerStats } from './server-stats.entity';
export { ServerStatsHourly } from './server-stats-hourly.entity';
export { PublicSiteConfig } from './public-site-config.entity';
export { PlatformChangelog } from './platform-changelog.entity';
export { MigrationExport } from './migration-export.entity';
export { EarlyAccessSignup } from './early-access-signup.entity';
export { PlayerSession } from './player-session.entity';
export { AlertConfig } from './alert-config.entity';
export { AlertHistory } from './alert-history.entity';
export { Module } from './module.entity';
export { ModulePurchase } from './module-purchase.entity';
export { ModuleInstallation } from './module-installation.entity';
export { PaymentOrder } from './payment-order.entity';
export { WebstoreSubscription } from './webstore-subscription.entity';
export { StoreConfig } from './store-config.entity';
export { StoreCategory } from './store-category.entity';
export { StoreItem } from './store-item.entity';
export { StoreTransaction } from './store-transaction.entity';
export { Host } from './host.entity';
export { HostLicense } from './host-license.entity';
export { HostBillingRecord } from './host-billing-record.entity';

View File

@@ -0,0 +1,46 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { User } from './user.entity';
@Entity('licenses')
@Check(`"status" IN ('active', 'suspended', 'expired', 'revoked')`)
export class License {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 64, unique: true })
license_key: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string;
@Column({ type: 'uuid' })
owner_user_id: string;
@Column({ type: 'varchar', length: 100, nullable: true })
server_name: string | null;
@Column({ type: 'varchar', length: 63, unique: true, nullable: true })
subdomain: string | null;
@Column({ type: 'varchar', length: 255, unique: true, nullable: true })
custom_domain: string | null;
@Column('text', { array: true, default: '{}' })
modules_enabled: string[];
@Column({ type: 'boolean', default: false })
webstore_active: boolean;
@Column({ type: 'varchar', length: 100, nullable: true })
webstore_subscription_id: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', nullable: true })
expires_at: Date | null;
@ManyToOne(() => User, user => user.licenses, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_user_id' })
owner: User;
}

View File

@@ -0,0 +1,46 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
@Entity('map_library')
@Check(`"map_type" IN ('custom', 'procedural')`)
export class MapLibrary {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 255 })
filename: string;
@Column({ type: 'varchar', length: 255 })
display_name: string;
@Column({ type: 'text' })
storage_path: string;
@Column({ type: 'bigint', default: 0 })
file_size_bytes: number;
@Column({ type: 'varchar', length: 20, default: 'custom' })
map_type: string;
@Column({ type: 'integer', nullable: true })
seed: number | null;
@Column({ type: 'integer', nullable: true })
world_size: number | null;
@Column({ type: 'text', nullable: true })
thumbnail_path: string | null;
@Column({ type: 'varchar', length: 64 })
checksum: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
uploaded_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,30 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
import { MapLibrary } from './map-library.entity';
@Entity('map_rotations')
@Unique(['license_id', 'rotation_order'])
export class MapRotation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
map_id: string;
@Column({ type: 'integer', default: 0 })
rotation_order: number;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => MapLibrary, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'map_id' })
map: MapLibrary;
}

View File

@@ -0,0 +1,39 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { User } from './user.entity';
@Entity('migration_exports')
@Check(`"export_type" IN ('full', 'config_only', 'store_only')`)
export class MigrationExport {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 20, default: 'full' })
export_type: string;
@Column({ type: 'text' })
storage_path: string;
@Column({ type: 'bigint', default: 0 })
file_size_bytes: number;
@Column({ type: 'uuid' })
created_by: string;
@Column({ type: 'timestamptz' })
expires_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator: User;
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique, Check } from 'typeorm';
import { License } from './license.entity';
import { Module } from './module.entity';
@Entity('module_installations')
@Unique(['license_id', 'module_id'])
@Check(`"status" IN ('pending', 'installing', 'installed', 'failed')`)
export class ModuleInstallation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
module_id: string;
@Column({ type: 'varchar', length: 50, default: 'pending' })
status: string;
@Column({ type: 'timestamptz', nullable: true })
installed_at: Date | null;
@Column({ type: 'text', nullable: true })
error_message: string | null;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => Module, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'module_id' })
module: Module;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
import { Module } from './module.entity';
@Entity('module_purchases')
@Unique(['license_id', 'module_id'])
export class ModulePurchase {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
module_id: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
purchased_at: Date;
@Column({ type: 'varchar', length: 255, nullable: true })
transaction_id: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
amount_paid: number | null;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => Module, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'module_id' })
module: Module;
}

View File

@@ -0,0 +1,40 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('modules')
export class Module {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true })
slug: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
category: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price_usd: number;
@Column({ type: 'text', nullable: true })
preview_image_url: string | null;
@Column({ type: 'jsonb', nullable: true })
screenshots: any | null;
@Column({ type: 'jsonb', nullable: true })
features: any | null;
@Column({ type: 'varchar', length: 20 })
version: string;
@Column({ type: 'text', nullable: true })
plugin_file_url: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
}

View File

@@ -0,0 +1,58 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { Module } from './module.entity';
import { License } from './license.entity';
@Entity('payment_orders')
@Check(`"status" IN ('pending', 'completed', 'failed', 'refunded')`)
export class PaymentOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
order_id: string;
@Column({ type: 'uuid', nullable: true })
module_id: string | null;
@Column({ type: 'uuid', nullable: true })
webstore_subscription_id: string | null;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({ type: 'varchar', length: 50 })
status: string;
@Column({ type: 'varchar', length: 255, nullable: true })
transaction_id: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
payer_email: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', nullable: true })
completed_at: Date | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@ManyToOne(() => Module, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'module_id' })
module: Module | null;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => License, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'webstore_subscription_id' })
webstore_subscription: License | null;
}

View File

@@ -0,0 +1,23 @@
import { Entity, PrimaryGeneratedColumn, Column, Check } from 'typeorm';
@Entity('platform_changelog')
@Check(`"category" IN ('feature', 'bugfix', 'module', 'security')`)
export class PlatformChangelog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 20 })
version: string;
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'varchar', length: 20, default: 'feature' })
category: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
published_at: Date;
}

View File

@@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { User } from './user.entity';
@Entity('player_actions')
@Check(`"action_type" IN ('kick', 'ban', 'unban', 'warn', 'note')`)
export class PlayerAction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 20 })
steam_id: string;
@Column({ type: 'varchar', length: 100 })
player_name: string;
@Column({ type: 'varchar', length: 20 })
action_type: string;
@Column({ type: 'text', nullable: true })
reason: string | null;
@Column({ type: 'integer', nullable: true })
duration_minutes: number | null;
@Column({ type: 'uuid' })
performed_by: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => User)
@JoinColumn({ name: 'performed_by' })
performer: User;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('player_sessions')
export class PlayerSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 20 })
steam_id: string;
@Column({ type: 'varchar', length: 100 })
player_name: string;
@Column({ type: 'timestamptz' })
session_start: Date;
@Column({ type: 'timestamptz', nullable: true })
session_end: Date | null;
@Column({ type: 'integer', nullable: true })
duration_seconds: number | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,59 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique, Check } from 'typeorm';
import { License } from './license.entity';
@Entity('plugin_registry')
@Unique(['license_id', 'plugin_name'])
@Check(`"source" IN ('umod', 'corrosion_module', 'manual')`)
export class PluginRegistry {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 255 })
plugin_name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
plugin_version: string | null;
@Column({ type: 'varchar', length: 20, default: 'manual' })
source: string;
@Column({ type: 'varchar', length: 255, nullable: true })
umod_slug: string | null;
@Column({ type: 'boolean', default: false })
is_installed: boolean;
@Column({ type: 'boolean', default: false })
is_loaded: boolean;
@Column({ type: 'jsonb', nullable: true })
config_json: Record<string, any> | null;
@Column({ type: 'text', nullable: true })
data_path: string | null;
@Column({ type: 'boolean', default: false })
wipe_on_map: boolean;
@Column({ type: 'boolean', default: false })
wipe_on_bp: boolean;
@Column({ type: 'boolean', default: false })
wipe_on_full: boolean;
@Column({ type: 'boolean', default: false })
never_wipe: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
installed_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,60 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('public_site_config')
export class PublicSiteConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
license_id: string;
@Column({ type: 'boolean', default: true })
site_enabled: boolean;
@Column({ type: 'boolean', default: false })
show_on_status_page: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
steam_connect_url: string | null;
@Column({ type: 'text', nullable: true })
motd: string | null;
@Column('text', { array: true, default: '{}' })
public_mods: string[];
@Column({ type: 'text', nullable: true })
header_image_url: string | null;
@Column({ type: 'varchar', length: 7, default: '#ef4444' })
theme_color: string;
@Column({ type: 'text', nullable: true })
custom_css: string | null;
@Column({ type: 'text', nullable: true })
discord_invite_url: string | null;
@Column({ type: 'boolean', default: true })
show_player_count: boolean;
@Column({ type: 'boolean', default: true })
show_wipe_schedule: boolean;
@Column({ type: 'boolean', default: true })
show_wipe_countdown: boolean;
@Column({ type: 'boolean', default: true })
show_mod_list: boolean;
@Column({ type: 'text', nullable: true })
status_page_description: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,80 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
import { MapLibrary } from './map-library.entity';
@Entity('server_config')
export class ServerConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
license_id: string;
@Column({ type: 'varchar', length: 255, default: '' })
server_name: string;
@Column({ type: 'integer', nullable: true })
max_players: number | null;
@Column({ type: 'integer', nullable: true })
world_size: number | null;
@Column({ type: 'integer', nullable: true })
current_seed: number | null;
@Column({ type: 'uuid', nullable: true })
current_map_id: string | null;
@Column({ type: 'text', nullable: true })
server_description: string | null;
@Column({ type: 'text', nullable: true })
server_url: string | null;
@Column({ type: 'text', nullable: true })
server_header_image: string | null;
@Column('text', { array: true, default: '{}' })
tags: string[];
@Column({ type: 'boolean', default: false })
auto_restart_enabled: boolean;
@Column({ type: 'varchar', length: 100, nullable: true })
auto_restart_cron: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
auto_restart_timezone: string | null;
@Column({ type: 'boolean', default: true })
crash_recovery_enabled: boolean;
@Column({ type: 'integer', default: 3 })
crash_recovery_max_attempts: number;
@Column({ type: 'integer', default: 10 })
crash_recovery_cooldown_minutes: number;
@Column({ type: 'boolean', default: true })
force_wipe_eligible: boolean;
@Column({ type: 'boolean', default: true })
auto_update_on_force_wipe: boolean;
@Column({ type: 'jsonb', default: {} })
config_overrides: Record<string, any>;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => MapLibrary, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'current_map_id' })
current_map: MapLibrary | null;
}

View File

@@ -0,0 +1,56 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
@Entity('server_connections')
@Check(`"connection_type" IN ('amp', 'pterodactyl', 'bare_metal')`)
@Check(`"connection_status" IN ('connected', 'degraded', 'offline')`)
export class ServerConnection {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
license_id: string;
@Column({ type: 'varchar', length: 20 })
connection_type: string;
@Column({ type: 'text', nullable: true })
panel_api_endpoint: string | null;
@Column({ type: 'text', nullable: true })
panel_api_key_encrypted: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
panel_server_identifier: string | null;
@Column({ type: 'varchar', length: 128, nullable: true })
companion_agent_token: string | null;
@Column({ type: 'timestamptz', nullable: true })
companion_last_seen: Date | null;
@Column({ type: 'timestamptz', nullable: true })
plugin_last_seen: Date | null;
@Column({ type: 'varchar', length: 45, nullable: true })
server_ip: string | null;
@Column({ type: 'integer', nullable: true })
server_port: number | null;
@Column({ type: 'integer', nullable: true })
game_port: number | null;
@Column({ type: 'varchar', length: 20, default: 'offline' })
connection_status: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
@Entity('server_stats_hourly')
@Unique(['license_id', 'hour'])
export class ServerStatsHourly {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'timestamptz' })
hour: Date;
@Column({ type: 'double precision', default: 0 })
avg_players: number;
@Column({ type: 'integer', default: 0 })
max_players: number;
@Column({ type: 'double precision', default: 0 })
avg_fps: number;
@Column({ type: 'double precision', default: 0 })
min_fps: number;
@Column({ type: 'integer', default: 0 })
avg_entities: number;
@Column({ type: 'double precision', default: 0 })
uptime_percentage: number;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,44 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
import { MapLibrary } from './map-library.entity';
@Entity('server_stats')
export class ServerStats {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'integer', default: 0 })
player_count: number;
@Column({ type: 'integer', default: 0 })
max_players: number;
@Column({ type: 'double precision', default: 0 })
fps: number;
@Column({ type: 'integer', default: 0 })
entity_count: number;
@Column({ type: 'integer', default: 0 })
uptime_seconds: number;
@Column({ type: 'integer', default: 0 })
memory_usage_mb: number;
@Column({ type: 'uuid', nullable: true })
map_id: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
recorded_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => MapLibrary, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'map_id' })
map: MapLibrary | null;
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
@Entity('store_categories')
@Unique(['license_id', 'slug'])
export class StoreCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 100 })
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'integer', default: 0 })
display_order: number;
@Column({ type: 'boolean', default: true })
visible: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('store_config')
export class StoreConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
license_id: string;
@Column({ type: 'varchar', length: 200 })
store_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({ type: 'varchar', length: 255, nullable: true })
paypal_client_id: string | null;
@Column({ type: 'text', nullable: true })
paypal_client_secret: string | null;
@Column({ type: 'boolean', default: true })
sandbox_mode: boolean;
@Column({ type: 'boolean', default: false })
enabled: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,54 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { StoreCategory } from './store-category.entity';
@Entity('store_items')
@Check(`"item_type" IN ('kit', 'rank', 'currency', 'command')`)
export class StoreItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid', nullable: true })
category_id: string | null;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@Column({ type: 'text', nullable: true })
image_url: string | null;
@Column({ type: 'varchar', length: 50 })
item_type: string;
@Column({ type: 'jsonb' })
delivery_commands: Record<string, any>;
@Column({ type: 'integer', nullable: true })
limit_per_player: number | null;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => StoreCategory, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'category_id' })
category: StoreCategory | null;
}

View File

@@ -0,0 +1,57 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { StoreItem } from './store-item.entity';
@Entity('store_transactions')
@Check(`"status" IN ('pending', 'paid', 'delivered', 'failed', 'refunded')`)
export class StoreTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid', nullable: true })
item_id: string | null;
@Column({ type: 'varchar', length: 20 })
steam_id: string;
@Column({ type: 'varchar', length: 100, nullable: true })
player_name: string | null;
@Column({ type: 'varchar', length: 255, unique: true })
paypal_order_id: string;
@Column({ type: 'varchar', length: 255, nullable: true })
paypal_transaction_id: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({ type: 'varchar', length: 50 })
status: string;
@Column({ type: 'boolean', default: false })
delivered: boolean;
@Column({ type: 'timestamptz', nullable: true })
delivered_at: Date | null;
@Column({ type: 'varchar', length: 255, nullable: true })
payer_email: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => StoreItem, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'item_id' })
item: StoreItem | null;
}

View File

@@ -0,0 +1,45 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { License } from './license.entity';
import { TeamMember } from './team-member.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
email: string;
@Column({ type: 'varchar', length: 50, unique: true })
username: string;
@Column({ type: 'text' })
password_hash: string;
@Column({ type: 'text', nullable: true })
totp_secret: string | null;
@Column({ type: 'boolean', default: false })
totp_enabled: boolean;
@Column('text', { array: true, nullable: true })
backup_codes: string[] | null;
@Column({ type: 'boolean', default: false })
email_verified: boolean;
@Column({ type: 'boolean', default: false })
is_super_admin: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', nullable: true })
last_login_at: Date | null;
@OneToMany(() => License, license => license.owner)
licenses: License[];
@OneToMany(() => TeamMember, teamMember => teamMember.user)
team_memberships: TeamMember[];
}

View File

@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
@Entity('webstore_subscriptions')
@Check(`"status" IN ('active', 'cancelled', 'suspended', 'past_due')`)
export class WebstoreSubscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
license_id: string;
@Column({ type: 'varchar', length: 255, unique: true })
paypal_subscription_id: string;
@Column({ type: 'varchar', length: 100 })
plan_id: string;
@Column({ type: 'varchar', length: 50 })
status: string;
@Column({ type: 'timestamptz' })
current_period_start: Date;
@Column({ type: 'timestamptz' })
current_period_end: Date;
@Column({ type: 'timestamptz', nullable: true })
cancelled_at: Date | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,78 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { WipeSchedule } from './wipe-schedule.entity';
import { WipeProfile } from './wipe-profile.entity';
import { MapLibrary } from './map-library.entity';
@Entity('wipe_history')
@Check(`"wipe_type" IN ('map', 'blueprint', 'full')`)
@Check(`"trigger_type" IN ('scheduled', 'manual', 'force_wipe')`)
@Check(`"status" IN ('pending', 'pre_wipe', 'wiping', 'post_wipe', 'success', 'failed', 'rolled_back')`)
export class WipeHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid', nullable: true })
wipe_schedule_id: string | null;
@Column({ type: 'uuid' })
wipe_profile_id: string;
@Column({ type: 'varchar', length: 20 })
wipe_type: string;
@Column({ type: 'varchar', length: 20 })
trigger_type: string;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: string;
@Column({ type: 'timestamptz', nullable: true })
started_at: Date | null;
@Column({ type: 'timestamptz', nullable: true })
completed_at: Date | null;
@Column({ type: 'varchar', length: 255, nullable: true })
map_used_legacy: string | null;
@Column({ type: 'uuid', nullable: true })
map_id: string | null;
@Column('text', { array: true, nullable: true })
plugins_wiped: string[] | null;
@Column('text', { array: true, nullable: true })
plugins_preserved: string[] | null;
@Column({ type: 'text', nullable: true })
backup_reference: string | null;
@Column({ type: 'text', nullable: true })
error_message: string | null;
@Column({ type: 'jsonb', default: '[]' })
execution_log: any[];
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => WipeSchedule, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'wipe_schedule_id' })
wipe_schedule: WipeSchedule | null;
@ManyToOne(() => WipeProfile)
@JoinColumn({ name: 'wipe_profile_id' })
wipe_profile: WipeProfile;
@ManyToOne(() => MapLibrary, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'map_id' })
map: MapLibrary | null;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('wipe_profiles')
export class WipeProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
profile_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', nullable: true })
pre_wipe_config: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true })
post_wipe_config: Record<string, any> | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check } from 'typeorm';
import { License } from './license.entity';
import { WipeProfile } from './wipe-profile.entity';
@Entity('wipe_schedules')
@Check(`"wipe_type" IN ('map', 'blueprint', 'full')`)
export class WipeSchedule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
wipe_profile_id: string;
@Column({ type: 'varchar', length: 100 })
schedule_name: string;
@Column({ type: 'varchar', length: 20 })
wipe_type: string;
@Column({ type: 'varchar', length: 100 })
cron_expression: string;
@Column({ type: 'varchar', length: 50, default: 'America/New_York' })
timezone: string;
@Column({ type: 'boolean', default: false })
wipe_blueprints: boolean;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@Column({ type: 'timestamptz', nullable: true })
next_scheduled_run: Date | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => WipeProfile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'wipe_profile_id' })
wipe_profile: WipeProfile;
}

View File

@@ -0,0 +1,106 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { NatsBridgeService } from '../services/nats-bridge.service';
import { NatsService } from '../services/nats.service';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
licenseId: string;
email: string;
};
}
@WebSocketGateway({
namespace: '/ws',
cors: { origin: '*' },
})
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(NatsBridgeGateway.name);
@WebSocketServer()
server!: Server;
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private natsBridge: NatsBridgeService,
private natsService: NatsService,
) {}
async handleConnection(client: AuthenticatedSocket) {
try {
const token = client.handshake.query.token as string;
if (!token) {
client.emit('error', { message: 'Authentication required' });
client.disconnect();
return;
}
const secret = this.configService.get<string>('jwt.secret');
const payload = this.jwtService.verify(token, { secret });
client.data = {
userId: payload.sub,
licenseId: payload.license_id,
email: payload.email,
};
if (payload.license_id) {
await client.join(`license:${payload.license_id}`);
}
if (payload.license_id) {
const listener = (event: string, data: unknown) => {
client.emit('event', {
type: 'event',
license_id: payload.license_id,
event,
data,
});
};
this.natsBridge.addListener(payload.license_id, listener);
(client as Socket & { _natsListener?: typeof listener })._natsListener = listener;
}
client.emit('connected', { type: 'connected', license_id: payload.license_id });
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
} catch {
client.emit('error', { message: 'Invalid token' });
client.disconnect();
}
}
handleDisconnect(client: AuthenticatedSocket) {
if (client.data?.licenseId) {
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener;
if (listener) {
this.natsBridge.removeListener(client.data.licenseId, listener);
}
}
}
@SubscribeMessage('console_input')
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) {
if (!client.data?.licenseId) return;
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command });
}
sendToLicense(licenseId: string, event: string, data: unknown): void {
this.server.to(`license:${licenseId}`).emit(event, {
type: 'event',
license_id: licenseId,
event,
data,
});
}
}

50
backend-nest/src/main.ts Normal file
View File

@@ -0,0 +1,50 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global prefix — all routes under /api
app.setGlobalPrefix('api');
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// Global exception filter
app.useGlobalFilters(new HttpExceptionFilter());
// Global response transform
app.useGlobalInterceptors(new TransformInterceptor());
// CORS
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5174',
credentials: true,
});
// Swagger
const swaggerConfig = new DocumentBuilder()
.setTitle('Corrosion API')
.setDescription('Corrosion Admin Panel — Game Server Management Platform')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.API_PORT || 3000;
await app.listen(port);
console.log(`Corrosion API running on port ${port}`);
}
bootstrap();

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

Some files were not shown because too many files have changed in this diff Show More