feat: Complete NestJS backend scaffold — 22 modules, 39 entities, WebSocket gateway
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full backend rewrite from Rust/Axum to NestJS/TypeScript. - 22 feature modules (auth, servers, wipes, maps, plugins, players, console, chat, team, notifications, settings, schedules, analytics, alerts, status, store, webstore, admin, setup, migration, users, licenses) - 39 TypeORM entities matching PostgreSQL schema (12 migrations) - Common infrastructure: JWT/RBAC guards, decorators, exception filter - NATS service with pub/sub/request-reply - Socket.IO WebSocket gateway with NATS bridge - Docker: NestJS Dockerfile + updated docker-compose.yml - Zero compile errors (npx tsc --noEmit clean) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
81
backend-nest/src/modules/admin/admin.controller.ts
Normal file
81
backend-nest/src/modules/admin/admin.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
backend-nest/src/modules/admin/admin.module.ts
Normal file
23
backend-nest/src/modules/admin/admin.module.ts
Normal 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 {}
|
||||
168
backend-nest/src/modules/admin/admin.service.ts
Normal file
168
backend-nest/src/modules/admin/admin.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user