diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 4a8056a..0777091 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -43,6 +43,7 @@ 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'; +import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -121,6 +122,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; FurnaceSplitterModule, BetterChatModule, TimedExecuteModule, + RaidableBasesModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/entities/raidablebases-config.entity.ts b/backend-nest/src/entities/raidablebases-config.entity.ts new file mode 100644 index 0000000..6c163b3 --- /dev/null +++ b/backend-nest/src/entities/raidablebases-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('raidablebases_configs') +export class RaidableBasesConfig { + @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/raidablebases/dto/create-raidablebases-config.dto.ts b/backend-nest/src/modules/raidablebases/dto/create-raidablebases-config.dto.ts new file mode 100644 index 0000000..efe9e9a --- /dev/null +++ b/backend-nest/src/modules/raidablebases/dto/create-raidablebases-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateRaidableBasesConfigDto { + @ApiProperty({ example: 'Default RaidableBases Config' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard RaidableBases settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/raidablebases/dto/import-raidablebases-config.dto.ts b/backend-nest/src/modules/raidablebases/dto/import-raidablebases-config.dto.ts new file mode 100644 index 0000000..ade7255 --- /dev/null +++ b/backend-nest/src/modules/raidablebases/dto/import-raidablebases-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportRaidableBasesConfigDto { + @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/raidablebases/dto/update-raidablebases-config.dto.ts b/backend-nest/src/modules/raidablebases/dto/update-raidablebases-config.dto.ts new file mode 100644 index 0000000..3ba54eb --- /dev/null +++ b/backend-nest/src/modules/raidablebases/dto/update-raidablebases-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateRaidableBasesConfigDto { + @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/raidablebases/raidablebases.controller.ts b/backend-nest/src/modules/raidablebases/raidablebases.controller.ts new file mode 100644 index 0000000..86ed77e --- /dev/null +++ b/backend-nest/src/modules/raidablebases/raidablebases.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 { RaidableBasesService } from './raidablebases.service'; +import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto'; +import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto'; +import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-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('raidablebases') +@ApiBearerAuth() +@Controller('raidablebases') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class RaidableBasesController { + constructor(private readonly raidableBasesService: RaidableBasesService) {} + + @Get('configs') + @RequirePermission('raidablebases.view') + @ApiOperation({ summary: 'List RaidableBases configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.raidableBasesService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('raidablebases.view') + @ApiOperation({ summary: 'Get full RaidableBases config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.raidableBasesService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('raidablebases.manage') + @ApiOperation({ summary: 'Create RaidableBases config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) { + return this.raidableBasesService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('raidablebases.manage') + @ApiOperation({ summary: 'Update RaidableBases config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateRaidableBasesConfigDto, + ) { + return this.raidableBasesService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('raidablebases.manage') + @ApiOperation({ summary: 'Delete RaidableBases config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.raidableBasesService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('raidablebases.manage') + @ApiOperation({ summary: 'Deploy RaidableBases config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.raidableBasesService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('raidablebases.manage') + @ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) { + return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/raidablebases/raidablebases.module.ts b/backend-nest/src/modules/raidablebases/raidablebases.module.ts new file mode 100644 index 0000000..54ff097 --- /dev/null +++ b/backend-nest/src/modules/raidablebases/raidablebases.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RaidableBasesController } from './raidablebases.controller'; +import { RaidableBasesService } from './raidablebases.service'; +import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([RaidableBasesConfig])], + controllers: [RaidableBasesController], + providers: [RaidableBasesService, NatsService], + exports: [RaidableBasesService], +}) +export class RaidableBasesModule {} diff --git a/backend-nest/src/modules/raidablebases/raidablebases.service.ts b/backend-nest/src/modules/raidablebases/raidablebases.service.ts new file mode 100644 index 0000000..8fcaafa --- /dev/null +++ b/backend-nest/src/modules/raidablebases/raidablebases.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 { RaidableBasesConfig } from '../../entities/raidablebases-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto'; +import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto'; + +@Injectable() +export class RaidableBasesService { + private readonly logger = new Logger(RaidableBasesService.name); + + constructor( + @InjectRepository(RaidableBasesConfig) + private readonly raidableBasesRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.raidableBasesRepo.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.raidableBasesRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('RaidableBases config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) { + const config = this.raidableBasesRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.raidableBasesRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) { + const config = await this.raidableBasesRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('RaidableBases 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.raidableBasesRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('RaidableBases config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.raidableBasesRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('RaidableBases config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write RaidableBases.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/RaidableBases.json', + content: jsonString, + }, + 30000, + ); + + // Reload RaidableBases plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload RaidableBases', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false }); + await this.raidableBasesRepo.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 RaidableBases config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy RaidableBases config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import RaidableBases.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read RaidableBases.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/RaidableBases.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 RaidableBases config row + const config = this.raidableBasesRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.raidableBasesRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import RaidableBases config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend/migrations/021_raidablebases_configs.sql b/backend/migrations/021_raidablebases_configs.sql new file mode 100644 index 0000000..b4cdc7e --- /dev/null +++ b/backend/migrations/021_raidablebases_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS raidablebases_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_raidablebases_configs_license ON raidablebases_configs(license_id); diff --git a/frontend/src/components/layout/DashboardLayout.vue b/frontend/src/components/layout/DashboardLayout.vue index d5e04a1..cf72b62 100644 --- a/frontend/src/components/layout/DashboardLayout.vue +++ b/frontend/src/components/layout/DashboardLayout.vue @@ -33,6 +33,7 @@ import { DoorOpen, Gift, Flame, + Swords, Menu, X, } from 'lucide-vue-next' @@ -58,6 +59,7 @@ const navItems = [ { name: 'Furnace Splitter', path: '/furnace-splitter', icon: Flame, permission: 'furnacesplitter.view' }, { name: 'Better Chat', path: '/better-chat', icon: MessageSquare, permission: 'betterchat.view' }, { name: 'Timed Execute', path: '/timed-execute', icon: Clock, permission: 'timedexecute.view' }, + { name: 'Raidable Bases', path: '/raidable-bases', icon: Swords, permission: 'raidablebases.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 6195bcc..4b63f74 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -150,6 +150,11 @@ const panelRoutes: RouteRecordRaw[] = [ name: 'timed-execute', component: () => import('@/views/admin/TimedExecuteView.vue'), }, + { + path: 'raidable-bases', + name: 'raidable-bases', + component: () => import('@/views/admin/RaidableBasesView.vue'), + }, { path: 'wipes', name: 'wipes', diff --git a/frontend/src/stores/raidablebases.ts b/frontend/src/stores/raidablebases.ts new file mode 100644 index 0000000..658a729 --- /dev/null +++ b/frontend/src/stores/raidablebases.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 { RaidableBasesConfigSummary, RaidableBasesConfigFull, RaidableBasesApplyResult } from '@/types' + +export const useRaidableBasesStore = defineStore('raidablebases', () => { + 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: RaidableBasesConfigSummary[] }>('/raidablebases/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: RaidableBasesConfigFull }>(`/raidablebases/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: RaidableBasesConfigFull }>('/raidablebases/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(`/raidablebases/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(`/raidablebases/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(`/raidablebases/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: RaidableBasesConfigFull }>('/raidablebases/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 5912cb6..866d0a4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -708,3 +708,30 @@ export interface TimedExecuteApplyResult { message: string config_name: string } + +// RaidableBases Config types +export interface RaidableBasesConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface RaidableBasesConfigFull { + 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 RaidableBasesApplyResult { + success: boolean + message: string + config_name: string +} diff --git a/frontend/src/views/admin/RaidableBasesView.vue b/frontend/src/views/admin/RaidableBasesView.vue new file mode 100644 index 0000000..afb43fa --- /dev/null +++ b/frontend/src/views/admin/RaidableBasesView.vue @@ -0,0 +1,1143 @@ + + +