From bb381569e3e3244ee004a2570b4ca1d96f6354ef Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sun, 22 Feb 2026 02:19:29 -0500 Subject: [PATCH] feat: Add BetterChat + TimedExecute plugin config modules - DB migrations 017 (betterchat_configs) and 020 (timedexecute_configs) applied - TypeORM entities matching production schema exactly - NestJS modules with full CRUD + apply-to-server + import-from-server - Pinia stores following teleport config pattern - BetterChatView: Chat Groups editor with color pickers, font sizes, format strings; Settings tab with word filter, anti-flood, player tagging - TimedExecuteView: TimerRepeat with presets, RealTime-Timer, OnConnect/OnDisconnect command lists - Wired into app.module.ts, router, DashboardLayout nav Co-Authored-By: Claude Opus 4.6 --- backend-nest/src/app.module.ts | 4 + .../src/entities/betterchat-config.entity.ts | 33 + .../entities/timedexecute-config.entity.ts | 33 + .../betterchat/betterchat.controller.ts | 80 ++ .../modules/betterchat/betterchat.module.ts | 14 + .../modules/betterchat/betterchat.service.ts | 180 ++++ .../dto/create-betterchat-config.dto.ts | 19 + .../dto/import-betterchat-config.dto.ts | 14 + .../dto/update-betterchat-config.dto.ts | 25 + .../dto/create-timedexecute-config.dto.ts | 19 + .../dto/import-timedexecute-config.dto.ts | 14 + .../dto/update-timedexecute-config.dto.ts | 25 + .../timedexecute/timedexecute.controller.ts | 80 ++ .../timedexecute/timedexecute.module.ts | 14 + .../timedexecute/timedexecute.service.ts | 180 ++++ backend/migrations/017_betterchat_configs.sql | 11 + .../migrations/020_timedexecute_configs.sql | 11 + frontend/src/stores/betterchat.ts | 145 ++++ frontend/src/stores/timedexecute.ts | 145 ++++ frontend/src/views/admin/BetterChatView.vue | 790 ++++++++++++++++++ frontend/src/views/admin/TimedExecuteView.vue | 656 +++++++++++++++ 21 files changed, 2492 insertions(+) create mode 100644 backend-nest/src/entities/betterchat-config.entity.ts create mode 100644 backend-nest/src/entities/timedexecute-config.entity.ts create mode 100644 backend-nest/src/modules/betterchat/betterchat.controller.ts create mode 100644 backend-nest/src/modules/betterchat/betterchat.module.ts create mode 100644 backend-nest/src/modules/betterchat/betterchat.service.ts create mode 100644 backend-nest/src/modules/betterchat/dto/create-betterchat-config.dto.ts create mode 100644 backend-nest/src/modules/betterchat/dto/import-betterchat-config.dto.ts create mode 100644 backend-nest/src/modules/betterchat/dto/update-betterchat-config.dto.ts create mode 100644 backend-nest/src/modules/timedexecute/dto/create-timedexecute-config.dto.ts create mode 100644 backend-nest/src/modules/timedexecute/dto/import-timedexecute-config.dto.ts create mode 100644 backend-nest/src/modules/timedexecute/dto/update-timedexecute-config.dto.ts create mode 100644 backend-nest/src/modules/timedexecute/timedexecute.controller.ts create mode 100644 backend-nest/src/modules/timedexecute/timedexecute.module.ts create mode 100644 backend-nest/src/modules/timedexecute/timedexecute.service.ts create mode 100644 backend/migrations/017_betterchat_configs.sql create mode 100644 backend/migrations/020_timedexecute_configs.sql create mode 100644 frontend/src/stores/betterchat.ts create mode 100644 frontend/src/stores/timedexecute.ts create mode 100644 frontend/src/views/admin/BetterChatView.vue create mode 100644 frontend/src/views/admin/TimedExecuteView.vue diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index df3caa5..4a8056a 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -41,6 +41,8 @@ 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'; +import { BetterChatModule } from './modules/betterchat/betterchat.module'; +import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -117,6 +119,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; AutoDoorsModule, KitsModule, FurnaceSplitterModule, + BetterChatModule, + TimedExecuteModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/entities/betterchat-config.entity.ts b/backend-nest/src/entities/betterchat-config.entity.ts new file mode 100644 index 0000000..9362c09 --- /dev/null +++ b/backend-nest/src/entities/betterchat-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('betterchat_configs') +export class BetterChatConfig { + @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/timedexecute-config.entity.ts b/backend-nest/src/entities/timedexecute-config.entity.ts new file mode 100644 index 0000000..0c71520 --- /dev/null +++ b/backend-nest/src/entities/timedexecute-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('timedexecute_configs') +export class TimedExecuteConfig { + @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/betterchat/betterchat.controller.ts b/backend-nest/src/modules/betterchat/betterchat.controller.ts new file mode 100644 index 0000000..edf00f3 --- /dev/null +++ b/backend-nest/src/modules/betterchat/betterchat.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 { BetterChatService } from './betterchat.service'; +import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto'; +import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto'; +import { ImportBetterChatConfigDto } from './dto/import-betterchat-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('betterchat') +@ApiBearerAuth() +@Controller('betterchat') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class BetterChatController { + constructor(private readonly betterChatService: BetterChatService) {} + + @Get('configs') + @RequirePermission('betterchat.view') + @ApiOperation({ summary: 'List BetterChat configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.betterChatService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('betterchat.view') + @ApiOperation({ summary: 'Get full BetterChat config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.betterChatService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('betterchat.manage') + @ApiOperation({ summary: 'Create BetterChat config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) { + return this.betterChatService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('betterchat.manage') + @ApiOperation({ summary: 'Update BetterChat config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateBetterChatConfigDto, + ) { + return this.betterChatService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('betterchat.manage') + @ApiOperation({ summary: 'Delete BetterChat config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.betterChatService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('betterchat.manage') + @ApiOperation({ summary: 'Deploy BetterChat config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.betterChatService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('betterchat.manage') + @ApiOperation({ summary: 'Import BetterChat.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) { + return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/betterchat/betterchat.module.ts b/backend-nest/src/modules/betterchat/betterchat.module.ts new file mode 100644 index 0000000..c721edf --- /dev/null +++ b/backend-nest/src/modules/betterchat/betterchat.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BetterChatController } from './betterchat.controller'; +import { BetterChatService } from './betterchat.service'; +import { BetterChatConfig } from '../../entities/betterchat-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([BetterChatConfig])], + controllers: [BetterChatController], + providers: [BetterChatService, NatsService], + exports: [BetterChatService], +}) +export class BetterChatModule {} diff --git a/backend-nest/src/modules/betterchat/betterchat.service.ts b/backend-nest/src/modules/betterchat/betterchat.service.ts new file mode 100644 index 0000000..7999b1e --- /dev/null +++ b/backend-nest/src/modules/betterchat/betterchat.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 { BetterChatConfig } from '../../entities/betterchat-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto'; +import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto'; + +@Injectable() +export class BetterChatService { + private readonly logger = new Logger(BetterChatService.name); + + constructor( + @InjectRepository(BetterChatConfig) + private readonly repo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.repo.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.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('BetterChat config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) { + const config = this.repo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.repo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) { + const config = await this.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('BetterChat 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.repo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.repo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('BetterChat config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('BetterChat config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write BetterChat.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/BetterChat.json', + content: jsonString, + }, + 30000, + ); + + // Reload BetterChat plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload BetterChat', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.repo.update({ license_id: licenseId }, { is_active: false }); + await this.repo.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 BetterChat config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy BetterChat config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import BetterChat.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read BetterChat.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/BetterChat.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 config row + const config = this.repo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.repo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import BetterChat config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend-nest/src/modules/betterchat/dto/create-betterchat-config.dto.ts b/backend-nest/src/modules/betterchat/dto/create-betterchat-config.dto.ts new file mode 100644 index 0000000..f1902c6 --- /dev/null +++ b/backend-nest/src/modules/betterchat/dto/create-betterchat-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateBetterChatConfigDto { + @ApiProperty({ example: 'Default Chat Config' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard BetterChat settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/betterchat/dto/import-betterchat-config.dto.ts b/backend-nest/src/modules/betterchat/dto/import-betterchat-config.dto.ts new file mode 100644 index 0000000..f4f671e --- /dev/null +++ b/backend-nest/src/modules/betterchat/dto/import-betterchat-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportBetterChatConfigDto { + @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/betterchat/dto/update-betterchat-config.dto.ts b/backend-nest/src/modules/betterchat/dto/update-betterchat-config.dto.ts new file mode 100644 index 0000000..20e87bc --- /dev/null +++ b/backend-nest/src/modules/betterchat/dto/update-betterchat-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateBetterChatConfigDto { + @ApiPropertyOptional({ example: 'Updated Chat 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/timedexecute/dto/create-timedexecute-config.dto.ts b/backend-nest/src/modules/timedexecute/dto/create-timedexecute-config.dto.ts new file mode 100644 index 0000000..9e7cc21 --- /dev/null +++ b/backend-nest/src/modules/timedexecute/dto/create-timedexecute-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTimedExecuteConfigDto { + @ApiProperty({ example: 'Default Timer Config' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard TimedExecute settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/timedexecute/dto/import-timedexecute-config.dto.ts b/backend-nest/src/modules/timedexecute/dto/import-timedexecute-config.dto.ts new file mode 100644 index 0000000..570d9f3 --- /dev/null +++ b/backend-nest/src/modules/timedexecute/dto/import-timedexecute-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportTimedExecuteConfigDto { + @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/timedexecute/dto/update-timedexecute-config.dto.ts b/backend-nest/src/modules/timedexecute/dto/update-timedexecute-config.dto.ts new file mode 100644 index 0000000..3e82e51 --- /dev/null +++ b/backend-nest/src/modules/timedexecute/dto/update-timedexecute-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTimedExecuteConfigDto { + @ApiPropertyOptional({ example: 'Updated Timer 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/timedexecute/timedexecute.controller.ts b/backend-nest/src/modules/timedexecute/timedexecute.controller.ts new file mode 100644 index 0000000..2b1d28d --- /dev/null +++ b/backend-nest/src/modules/timedexecute/timedexecute.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 { TimedExecuteService } from './timedexecute.service'; +import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto'; +import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto'; +import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-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('timedexecute') +@ApiBearerAuth() +@Controller('timedexecute') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class TimedExecuteController { + constructor(private readonly timedExecuteService: TimedExecuteService) {} + + @Get('configs') + @RequirePermission('timedexecute.view') + @ApiOperation({ summary: 'List TimedExecute configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.timedExecuteService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('timedexecute.view') + @ApiOperation({ summary: 'Get full TimedExecute config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.timedExecuteService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('timedexecute.manage') + @ApiOperation({ summary: 'Create TimedExecute config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) { + return this.timedExecuteService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('timedexecute.manage') + @ApiOperation({ summary: 'Update TimedExecute config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateTimedExecuteConfigDto, + ) { + return this.timedExecuteService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('timedexecute.manage') + @ApiOperation({ summary: 'Delete TimedExecute config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.timedExecuteService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('timedexecute.manage') + @ApiOperation({ summary: 'Deploy TimedExecute config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.timedExecuteService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('timedexecute.manage') + @ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) { + return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/timedexecute/timedexecute.module.ts b/backend-nest/src/modules/timedexecute/timedexecute.module.ts new file mode 100644 index 0000000..fe4a219 --- /dev/null +++ b/backend-nest/src/modules/timedexecute/timedexecute.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TimedExecuteController } from './timedexecute.controller'; +import { TimedExecuteService } from './timedexecute.service'; +import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TimedExecuteConfig])], + controllers: [TimedExecuteController], + providers: [TimedExecuteService, NatsService], + exports: [TimedExecuteService], +}) +export class TimedExecuteModule {} diff --git a/backend-nest/src/modules/timedexecute/timedexecute.service.ts b/backend-nest/src/modules/timedexecute/timedexecute.service.ts new file mode 100644 index 0000000..d571d54 --- /dev/null +++ b/backend-nest/src/modules/timedexecute/timedexecute.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 { TimedExecuteConfig } from '../../entities/timedexecute-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto'; +import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto'; + +@Injectable() +export class TimedExecuteService { + private readonly logger = new Logger(TimedExecuteService.name); + + constructor( + @InjectRepository(TimedExecuteConfig) + private readonly repo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.repo.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.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('TimedExecute config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) { + const config = this.repo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.repo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) { + const config = await this.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('TimedExecute 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.repo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.repo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('TimedExecute config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.repo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('TimedExecute config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write TimedExecute.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/TimedExecute.json', + content: jsonString, + }, + 30000, + ); + + // Reload TimedExecute plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload TimedExecute', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.repo.update({ license_id: licenseId }, { is_active: false }); + await this.repo.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 TimedExecute config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy TimedExecute config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import TimedExecute.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read TimedExecute.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/TimedExecute.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 config row + const config = this.repo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.repo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import TimedExecute config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend/migrations/017_betterchat_configs.sql b/backend/migrations/017_betterchat_configs.sql new file mode 100644 index 0000000..d2d6f31 --- /dev/null +++ b/backend/migrations/017_betterchat_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS betterchat_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_betterchat_configs_license ON betterchat_configs(license_id); diff --git a/backend/migrations/020_timedexecute_configs.sql b/backend/migrations/020_timedexecute_configs.sql new file mode 100644 index 0000000..1fe73b1 --- /dev/null +++ b/backend/migrations/020_timedexecute_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS timedexecute_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_timedexecute_configs_license ON timedexecute_configs(license_id); diff --git a/frontend/src/stores/betterchat.ts b/frontend/src/stores/betterchat.ts new file mode 100644 index 0000000..6908e59 --- /dev/null +++ b/frontend/src/stores/betterchat.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 { BetterChatConfigSummary, BetterChatConfigFull, BetterChatApplyResult } from '@/types' + +export const useBetterChatStore = defineStore('betterchat', () => { + 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: BetterChatConfigSummary[] }>('/betterchat/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: BetterChatConfigFull }>(`/betterchat/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: BetterChatConfigFull }>('/betterchat/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(`/betterchat/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(`/betterchat/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(`/betterchat/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: BetterChatConfigFull }>('/betterchat/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/timedexecute.ts b/frontend/src/stores/timedexecute.ts new file mode 100644 index 0000000..d182c28 --- /dev/null +++ b/frontend/src/stores/timedexecute.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 { TimedExecuteConfigSummary, TimedExecuteConfigFull, TimedExecuteApplyResult } from '@/types' + +export const useTimedExecuteStore = defineStore('timedexecute', () => { + 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: TimedExecuteConfigSummary[] }>('/timedexecute/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: TimedExecuteConfigFull }>(`/timedexecute/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: TimedExecuteConfigFull }>('/timedexecute/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(`/timedexecute/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(`/timedexecute/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(`/timedexecute/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: TimedExecuteConfigFull }>('/timedexecute/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/views/admin/BetterChatView.vue b/frontend/src/views/admin/BetterChatView.vue new file mode 100644 index 0000000..b521f8e --- /dev/null +++ b/frontend/src/views/admin/BetterChatView.vue @@ -0,0 +1,790 @@ + + +