diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 6b2f39c..df3caa5 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -37,6 +37,10 @@ import { ChangelogModule } from './modules/changelog/changelog.module'; import { FilesModule } from './modules/files/files.module'; import { LootModule } from './modules/loot/loot.module'; import { TeleportModule } from './modules/teleport/teleport.module'; +import { GatherModule } from './modules/gather/gather.module'; +import { AutoDoorsModule } from './modules/autodoors/autodoors.module'; +import { KitsModule } from './modules/kits/kits.module'; +import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -109,6 +113,10 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; FilesModule, LootModule, TeleportModule, + GatherModule, + AutoDoorsModule, + KitsModule, + FurnaceSplitterModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/entities/autodoors-config.entity.ts b/backend-nest/src/entities/autodoors-config.entity.ts new file mode 100644 index 0000000..4e023a2 --- /dev/null +++ b/backend-nest/src/entities/autodoors-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('autodoors_configs') +export class AutoDoorsConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + license_id: string; + + @Column({ type: 'varchar', length: 100 }) + config_name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: () => "'{}'" }) + config_data: Record; + + @Column({ type: 'boolean', default: false }) + is_active: 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; +} diff --git a/backend-nest/src/entities/gather-config.entity.ts b/backend-nest/src/entities/gather-config.entity.ts new file mode 100644 index 0000000..a678418 --- /dev/null +++ b/backend-nest/src/entities/gather-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('gather_configs') +export class GatherConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + license_id: string; + + @Column({ type: 'varchar', length: 100 }) + config_name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: () => "'{}'" }) + config_data: Record; + + @Column({ type: 'boolean', default: false }) + is_active: 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; +} diff --git a/backend-nest/src/modules/autodoors/autodoors.controller.ts b/backend-nest/src/modules/autodoors/autodoors.controller.ts new file mode 100644 index 0000000..05ef391 --- /dev/null +++ b/backend-nest/src/modules/autodoors/autodoors.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { AutoDoorsService } from './autodoors.service'; +import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto'; +import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto'; +import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-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('autodoors') +@ApiBearerAuth() +@Controller('autodoors') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class AutoDoorsController { + constructor(private readonly autoDoorsService: AutoDoorsService) {} + + @Get('configs') + @RequirePermission('autodoors.view') + @ApiOperation({ summary: 'List AutoDoors configs' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.autoDoorsService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('autodoors.view') + @ApiOperation({ summary: 'Get full AutoDoors config' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.autoDoorsService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('autodoors.manage') + @ApiOperation({ summary: 'Create AutoDoors config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) { + return this.autoDoorsService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('autodoors.manage') + @ApiOperation({ summary: 'Update AutoDoors config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateAutoDoorsConfigDto, + ) { + return this.autoDoorsService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('autodoors.manage') + @ApiOperation({ summary: 'Delete AutoDoors config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.autoDoorsService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('autodoors.manage') + @ApiOperation({ summary: 'Deploy AutoDoors config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.autoDoorsService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('autodoors.manage') + @ApiOperation({ summary: 'Import AutoDoors.json from server' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) { + return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/autodoors/autodoors.module.ts b/backend-nest/src/modules/autodoors/autodoors.module.ts new file mode 100644 index 0000000..e8a626d --- /dev/null +++ b/backend-nest/src/modules/autodoors/autodoors.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AutoDoorsController } from './autodoors.controller'; +import { AutoDoorsService } from './autodoors.service'; +import { AutoDoorsConfig } from '../../entities/autodoors-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AutoDoorsConfig])], + controllers: [AutoDoorsController], + providers: [AutoDoorsService, NatsService], + exports: [AutoDoorsService], +}) +export class AutoDoorsModule {} diff --git a/backend-nest/src/modules/autodoors/autodoors.service.ts b/backend-nest/src/modules/autodoors/autodoors.service.ts new file mode 100644 index 0000000..e3ef573 --- /dev/null +++ b/backend-nest/src/modules/autodoors/autodoors.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AutoDoorsConfig } from '../../entities/autodoors-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto'; +import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto'; + +@Injectable() +export class AutoDoorsService { + private readonly logger = new Logger(AutoDoorsService.name); + + constructor( + @InjectRepository(AutoDoorsConfig) + private readonly autoDoorsRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.autoDoorsRepo.find({ + where: { license_id: licenseId }, + select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'], + order: { created_at: 'DESC' }, + }); + return { configs }; + } + + /** Get full config with JSONB data */ + async getConfig(licenseId: string, configId: string) { + const config = await this.autoDoorsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('AutoDoors config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) { + const config = this.autoDoorsRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.autoDoorsRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) { + const config = await this.autoDoorsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('AutoDoors config not found'); + + if (dto.config_name !== undefined) config.config_name = dto.config_name; + if (dto.description !== undefined) config.description = dto.description; + if (dto.config_data !== undefined) config.config_data = dto.config_data; + if (dto.is_active !== undefined) config.is_active = dto.is_active; + config.updated_at = new Date(); + + const saved = await this.autoDoorsRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('AutoDoors config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.autoDoorsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('AutoDoors config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write AutoDoors.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/AutoDoors.json', + content: jsonString, + }, + 30000, + ); + + // Reload AutoDoors plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload AutoDoors', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false }); + await this.autoDoorsRepo.update( + { id: configId, license_id: licenseId }, + { is_active: true, updated_at: new Date() }, + ); + + return { + success: true, + message: `Config "${config.config_name}" deployed to server`, + config_name: config.config_name, + }; + } catch (error) { + this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy AutoDoors config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import AutoDoors.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read AutoDoors.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/AutoDoors.json', + }, + 30000, + ); + + if (!response) { + throw new HttpException( + 'No response from agent — it may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Parse the response content as JSON + const responseData = response as Record; + let configData: Record; + + if (typeof responseData.content === 'string') { + configData = JSON.parse(responseData.content); + } else if (typeof responseData.content === 'object') { + configData = responseData.content; + } else { + throw new HttpException( + 'Unexpected response format from agent', + HttpStatus.BAD_GATEWAY, + ); + } + + // Create new AutoDoors config row + const config = this.autoDoorsRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.autoDoorsRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import AutoDoors config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend-nest/src/modules/autodoors/dto/create-autodoors-config.dto.ts b/backend-nest/src/modules/autodoors/dto/create-autodoors-config.dto.ts new file mode 100644 index 0000000..55cf731 --- /dev/null +++ b/backend-nest/src/modules/autodoors/dto/create-autodoors-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateAutoDoorsConfigDto { + @ApiProperty({ example: 'Default AutoDoors' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard auto-close settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/autodoors/dto/import-autodoors-config.dto.ts b/backend-nest/src/modules/autodoors/dto/import-autodoors-config.dto.ts new file mode 100644 index 0000000..2efc5fa --- /dev/null +++ b/backend-nest/src/modules/autodoors/dto/import-autodoors-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportAutoDoorsConfigDto { + @ApiProperty({ example: 'Server Import' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Imported from live server' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend-nest/src/modules/autodoors/dto/update-autodoors-config.dto.ts b/backend-nest/src/modules/autodoors/dto/update-autodoors-config.dto.ts new file mode 100644 index 0000000..9887178 --- /dev/null +++ b/backend-nest/src/modules/autodoors/dto/update-autodoors-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateAutoDoorsConfigDto { + @ApiPropertyOptional({ example: 'Updated Config' }) + @IsString() + @MaxLength(100) + @IsOptional() + config_name?: string; + + @ApiPropertyOptional({ example: 'Updated description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + is_active?: boolean; +} diff --git a/backend-nest/src/modules/gather/dto/create-gather-config.dto.ts b/backend-nest/src/modules/gather/dto/create-gather-config.dto.ts new file mode 100644 index 0000000..0af2fb8 --- /dev/null +++ b/backend-nest/src/modules/gather/dto/create-gather-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateGatherConfigDto { + @ApiProperty({ example: 'Default 2x Rates' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard 2x gather rates' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/gather/dto/import-gather-config.dto.ts b/backend-nest/src/modules/gather/dto/import-gather-config.dto.ts new file mode 100644 index 0000000..a0363c9 --- /dev/null +++ b/backend-nest/src/modules/gather/dto/import-gather-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportGatherConfigDto { + @ApiProperty({ example: 'Server Import' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Imported from live server' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend-nest/src/modules/gather/dto/update-gather-config.dto.ts b/backend-nest/src/modules/gather/dto/update-gather-config.dto.ts new file mode 100644 index 0000000..01a2725 --- /dev/null +++ b/backend-nest/src/modules/gather/dto/update-gather-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateGatherConfigDto { + @ApiPropertyOptional({ example: 'Updated Rates' }) + @IsString() + @MaxLength(100) + @IsOptional() + config_name?: string; + + @ApiPropertyOptional({ example: 'Updated description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + is_active?: boolean; +} diff --git a/backend-nest/src/modules/gather/gather.controller.ts b/backend-nest/src/modules/gather/gather.controller.ts new file mode 100644 index 0000000..7c425d6 --- /dev/null +++ b/backend-nest/src/modules/gather/gather.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { GatherService } from './gather.service'; +import { CreateGatherConfigDto } from './dto/create-gather-config.dto'; +import { UpdateGatherConfigDto } from './dto/update-gather-config.dto'; +import { ImportGatherConfigDto } from './dto/import-gather-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('gather') +@ApiBearerAuth() +@Controller('gather') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class GatherController { + constructor(private readonly gatherService: GatherService) {} + + @Get('configs') + @RequirePermission('gather.view') + @ApiOperation({ summary: 'List gather configs' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.gatherService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('gather.view') + @ApiOperation({ summary: 'Get full gather config' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.gatherService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('gather.manage') + @ApiOperation({ summary: 'Create gather config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) { + return this.gatherService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('gather.manage') + @ApiOperation({ summary: 'Update gather config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateGatherConfigDto, + ) { + return this.gatherService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('gather.manage') + @ApiOperation({ summary: 'Delete gather config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.gatherService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('gather.manage') + @ApiOperation({ summary: 'Deploy gather config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.gatherService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('gather.manage') + @ApiOperation({ summary: 'Import GatherManager.json from server' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) { + return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/gather/gather.module.ts b/backend-nest/src/modules/gather/gather.module.ts new file mode 100644 index 0000000..ceaa35a --- /dev/null +++ b/backend-nest/src/modules/gather/gather.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GatherController } from './gather.controller'; +import { GatherService } from './gather.service'; +import { GatherConfig } from '../../entities/gather-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([GatherConfig])], + controllers: [GatherController], + providers: [GatherService, NatsService], + exports: [GatherService], +}) +export class GatherModule {} diff --git a/backend-nest/src/modules/gather/gather.service.ts b/backend-nest/src/modules/gather/gather.service.ts new file mode 100644 index 0000000..ff8f48e --- /dev/null +++ b/backend-nest/src/modules/gather/gather.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GatherConfig } from '../../entities/gather-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateGatherConfigDto } from './dto/create-gather-config.dto'; +import { UpdateGatherConfigDto } from './dto/update-gather-config.dto'; + +@Injectable() +export class GatherService { + private readonly logger = new Logger(GatherService.name); + + constructor( + @InjectRepository(GatherConfig) + private readonly gatherRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.gatherRepo.find({ + where: { license_id: licenseId }, + select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'], + order: { created_at: 'DESC' }, + }); + return { configs }; + } + + /** Get full config with JSONB data */ + async getConfig(licenseId: string, configId: string) { + const config = await this.gatherRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Gather config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateGatherConfigDto) { + const config = this.gatherRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.gatherRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) { + const config = await this.gatherRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Gather config not found'); + + if (dto.config_name !== undefined) config.config_name = dto.config_name; + if (dto.description !== undefined) config.description = dto.description; + if (dto.config_data !== undefined) config.config_data = dto.config_data; + if (dto.is_active !== undefined) config.is_active = dto.is_active; + config.updated_at = new Date(); + + const saved = await this.gatherRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('Gather config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.gatherRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Gather config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write GatherManager.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/GatherManager.json', + content: jsonString, + }, + 30000, + ); + + // Reload GatherManager plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload GatherManager', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.gatherRepo.update({ license_id: licenseId }, { is_active: false }); + await this.gatherRepo.update( + { id: configId, license_id: licenseId }, + { is_active: true, updated_at: new Date() }, + ); + + return { + success: true, + message: `Config "${config.config_name}" deployed to server`, + config_name: config.config_name, + }; + } catch (error) { + this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy gather config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import GatherManager.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read GatherManager.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/GatherManager.json', + }, + 30000, + ); + + if (!response) { + throw new HttpException( + 'No response from agent — it may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Parse the response content as JSON + const responseData = response as Record; + let configData: Record; + + if (typeof responseData.content === 'string') { + configData = JSON.parse(responseData.content); + } else if (typeof responseData.content === 'object') { + configData = responseData.content; + } else { + throw new HttpException( + 'Unexpected response format from agent', + HttpStatus.BAD_GATEWAY, + ); + } + + // Create new gather config row + const config = this.gatherRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.gatherRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import gather config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend/migrations/015_gather_configs.sql b/backend/migrations/015_gather_configs.sql new file mode 100644 index 0000000..597b4dd --- /dev/null +++ b/backend/migrations/015_gather_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS gather_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + config_name VARCHAR(100) NOT NULL, + description TEXT, + config_data JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_gather_configs_license ON gather_configs(license_id); diff --git a/backend/migrations/018_autodoors_configs.sql b/backend/migrations/018_autodoors_configs.sql new file mode 100644 index 0000000..986194c --- /dev/null +++ b/backend/migrations/018_autodoors_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS autodoors_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + config_name VARCHAR(100) NOT NULL, + description TEXT, + config_data JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_autodoors_configs_license ON autodoors_configs(license_id); diff --git a/frontend/src/components/layout/DashboardLayout.vue b/frontend/src/components/layout/DashboardLayout.vue index 44d7191..7107d8f 100644 --- a/frontend/src/components/layout/DashboardLayout.vue +++ b/frontend/src/components/layout/DashboardLayout.vue @@ -29,6 +29,8 @@ import { FolderOpen, Crosshair, Navigation2, + Pickaxe, + DoorOpen, Menu, X, } from 'lucide-vue-next' @@ -48,6 +50,8 @@ const navItems = [ { name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' }, { name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' }, { name: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.view' }, + { name: 'Gather Rates', path: '/gather-manager', icon: Pickaxe, permission: 'gather.view' }, + { name: 'Auto Doors', path: '/autodoors', icon: DoorOpen, permission: 'autodoors.view' }, { name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' }, { name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' }, { name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8c130e9..8281d9e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -120,6 +120,16 @@ const panelRoutes: RouteRecordRaw[] = [ name: 'teleport-config', component: () => import('@/views/admin/TeleportConfigView.vue'), }, + { + path: 'gather-manager', + name: 'gather-manager', + component: () => import('@/views/admin/GatherManagerView.vue'), + }, + { + path: 'autodoors', + name: 'autodoors', + component: () => import('@/views/admin/AutoDoorsView.vue'), + }, { path: 'wipes', name: 'wipes', diff --git a/frontend/src/stores/autodoors.ts b/frontend/src/stores/autodoors.ts new file mode 100644 index 0000000..1580c34 --- /dev/null +++ b/frontend/src/stores/autodoors.ts @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '@/composables/useApi' +import { useToastStore } from '@/stores/toast' +import type { AutoDoorsConfigSummary, AutoDoorsConfigFull, AutoDoorsApplyResult } from '@/types' + +export const useAutoDoorsStore = defineStore('autodoors', () => { + const configs = ref([]) + const currentConfig = ref(null) + const isLoading = ref(false) + const isSaving = ref(false) + const isApplying = ref(false) + const isDirty = ref(false) + const api = useApi() + const toast = useToastStore() + + async function fetchConfigs() { + isLoading.value = true + try { + const res = await api.get<{ configs: AutoDoorsConfigSummary[] }>('/autodoors/configs') + configs.value = res.configs + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function loadConfig(id: string) { + isLoading.value = true + try { + const res = await api.get<{ config: AutoDoorsConfigFull }>(`/autodoors/configs/${id}`) + currentConfig.value = res.config + isDirty.value = false + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function createConfig(name: string, description?: string) { + try { + const res = await api.post<{ config: AutoDoorsConfigFull }>('/autodoors/configs', { + config_name: name, + description, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config "${name}" created`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } + } + + async function saveCurrentConfig() { + if (!currentConfig.value) return + isSaving.value = true + try { + await api.put(`/autodoors/configs/${currentConfig.value.id}`, { + config_name: currentConfig.value.config_name, + description: currentConfig.value.description, + config_data: currentConfig.value.config_data, + }) + isDirty.value = false + await fetchConfigs() + toast.success('Config saved') + } catch (err) { + toast.error((err as Error).message) + } finally { + isSaving.value = false + } + } + + async function deleteConfig(id: string) { + try { + await api.del(`/autodoors/configs/${id}`) + if (currentConfig.value?.id === id) { + currentConfig.value = null + } + await fetchConfigs() + toast.success('Config deleted') + } catch (err) { + toast.error((err as Error).message) + } + } + + async function applyToServer(id: string) { + isApplying.value = true + try { + const res = await api.post(`/autodoors/configs/${id}/apply`) + await fetchConfigs() + toast.success(res.message) + return res + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isApplying.value = false + } + } + + async function importFromServer(configName: string) { + isLoading.value = true + try { + const res = await api.post<{ config: AutoDoorsConfigFull }>('/autodoors/import-from-server', { + config_name: configName, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config imported from server as "${configName}"`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isLoading.value = false + } + } + + function markDirty() { + isDirty.value = true + } + + return { + configs, + currentConfig, + isLoading, + isSaving, + isApplying, + isDirty, + fetchConfigs, + loadConfig, + createConfig, + saveCurrentConfig, + deleteConfig, + applyToServer, + importFromServer, + markDirty, + } +}) diff --git a/frontend/src/stores/gather.ts b/frontend/src/stores/gather.ts new file mode 100644 index 0000000..3750bf7 --- /dev/null +++ b/frontend/src/stores/gather.ts @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '@/composables/useApi' +import { useToastStore } from '@/stores/toast' +import type { GatherConfigSummary, GatherConfigFull, GatherApplyResult } from '@/types' + +export const useGatherStore = defineStore('gather', () => { + const configs = ref([]) + const currentConfig = ref(null) + const isLoading = ref(false) + const isSaving = ref(false) + const isApplying = ref(false) + const isDirty = ref(false) + const api = useApi() + const toast = useToastStore() + + async function fetchConfigs() { + isLoading.value = true + try { + const res = await api.get<{ configs: GatherConfigSummary[] }>('/gather/configs') + configs.value = res.configs + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function loadConfig(id: string) { + isLoading.value = true + try { + const res = await api.get<{ config: GatherConfigFull }>(`/gather/configs/${id}`) + currentConfig.value = res.config + isDirty.value = false + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function createConfig(name: string, description?: string) { + try { + const res = await api.post<{ config: GatherConfigFull }>('/gather/configs', { + config_name: name, + description, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config "${name}" created`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } + } + + async function saveCurrentConfig() { + if (!currentConfig.value) return + isSaving.value = true + try { + await api.put(`/gather/configs/${currentConfig.value.id}`, { + config_name: currentConfig.value.config_name, + description: currentConfig.value.description, + config_data: currentConfig.value.config_data, + }) + isDirty.value = false + await fetchConfigs() + toast.success('Config saved') + } catch (err) { + toast.error((err as Error).message) + } finally { + isSaving.value = false + } + } + + async function deleteConfig(id: string) { + try { + await api.del(`/gather/configs/${id}`) + if (currentConfig.value?.id === id) { + currentConfig.value = null + } + await fetchConfigs() + toast.success('Config deleted') + } catch (err) { + toast.error((err as Error).message) + } + } + + async function applyToServer(id: string) { + isApplying.value = true + try { + const res = await api.post(`/gather/configs/${id}/apply`) + await fetchConfigs() + toast.success(res.message) + return res + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isApplying.value = false + } + } + + async function importFromServer(configName: string) { + isLoading.value = true + try { + const res = await api.post<{ config: GatherConfigFull }>('/gather/import-from-server', { + config_name: configName, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config imported from server as "${configName}"`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isLoading.value = false + } + } + + function markDirty() { + isDirty.value = true + } + + return { + configs, + currentConfig, + isLoading, + isSaving, + isApplying, + isDirty, + fetchConfigs, + loadConfig, + createConfig, + saveCurrentConfig, + deleteConfig, + applyToServer, + importFromServer, + markDirty, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5b3c1be..d58b988 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -546,3 +546,111 @@ export interface TeleportApplyResult { message: string config_name: string } + +// GatherManager Config types +export interface GatherConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface GatherConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface GatherApplyResult { + success: boolean + message: string + config_name: string +} + +// AutoDoors Config types +export interface AutoDoorsConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface AutoDoorsConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface AutoDoorsApplyResult { + success: boolean + message: string + config_name: string +} + +// Kits Config types +export interface KitsConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface KitsConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface KitsApplyResult { + success: boolean + message: string + config_name: string +} + +// FurnaceSplitter Config types +export interface FurnaceSplitterConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface FurnaceSplitterConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface FurnaceSplitterApplyResult { + success: boolean + message: string + config_name: string +} diff --git a/frontend/src/views/admin/AutoDoorsView.vue b/frontend/src/views/admin/AutoDoorsView.vue new file mode 100644 index 0000000..678a3cb --- /dev/null +++ b/frontend/src/views/admin/AutoDoorsView.vue @@ -0,0 +1,595 @@ + + +