feat: Wire execution engines for schedules, alerts, wipes, and module install
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- schedules/: Add schedule executor in SchedulesService — polls every 60s for tasks where next_run <= now, dispatches NATS commands per task_type (restart, announcement, command, plugin_reload). Calculates next_run from cron expression on create/update/toggle. Bootstraps missing next_run values on startup. Wire NatsService into SchedulesModule. - alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all alert_config rows, queries latest server_stats per license, evaluates FPS degradation and population drop thresholds. Fires alert_history records on breach. Enforces 10-minute in-memory cooldown per alert type per license to prevent flooding. Wire ServerStats repo into AlertsModule. - wipes/: Replace hardcoded dry-run mock with profile-aware simulation. Resolves actual WipeProfile by ID (cross-tenant protected), builds would_delete/would_preserve lists from wipe_type, factors pre_wipe_config (backup, countdown warnings) and post_wipe_config (health checks, retry attempts) into estimated_duration_seconds. Returns profile_name and notes. - store/: Fix installModule stub — creates a real module_installations record with status='installed' and installed_at timestamp. Idempotent on retry, resets failed installations. Wire ModuleInstallation repo into StoreModule. getMyModules now returns real installation data instead of filtered purchases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||
controllers: [AlertsController],
|
||||
providers: [AlertsService],
|
||||
exports: [AlertsService],
|
||||
|
||||
@@ -1,26 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
||||
|
||||
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
|
||||
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
export class AlertsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
||||
private readonly cooldowns = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertConfig)
|
||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||
@InjectRepository(AlertHistory)
|
||||
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Poll every 90 seconds.
|
||||
this.evaluatorInterval = setInterval(() => {
|
||||
this.evaluateAllAlerts().catch(err =>
|
||||
this.logger.error('Alert evaluator error', err),
|
||||
);
|
||||
}, 90_000);
|
||||
|
||||
this.logger.log('Alert evaluator started (90s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.evaluatorInterval) {
|
||||
clearInterval(this.evaluatorInterval);
|
||||
this.evaluatorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluation engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async evaluateAllAlerts(): Promise<void> {
|
||||
// Load all alert configs in one query.
|
||||
const configs = await this.alertConfigRepo.find();
|
||||
|
||||
if (configs.length === 0) return;
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
await this.evaluateForLicense(config);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Alert evaluation failed for license ${config.license_id}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateForLicense(config: AlertConfig): Promise<void> {
|
||||
// Pull the most recent server_stats record for this license.
|
||||
const stats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
if (!stats) return; // No data yet — can't evaluate.
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// --- FPS degradation alert ---
|
||||
if (config.fps_degradation_enabled && stats.fps > 0) {
|
||||
if (stats.fps < config.fps_threshold) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'fps_degradation',
|
||||
'warning',
|
||||
'FPS Degradation Detected',
|
||||
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
|
||||
{
|
||||
current_fps: stats.fps,
|
||||
threshold: config.fps_threshold,
|
||||
player_count: stats.player_count,
|
||||
recorded_at: stats.recorded_at,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Population drop alert ---
|
||||
// We need two data points to detect a *drop*, so we compare current vs
|
||||
// the max_players recorded 30 minutes ago (nearest sample).
|
||||
if (config.population_drop_enabled && stats.max_players > 0) {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const previousStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
// Use a second query to get a historical data point
|
||||
const historicalStats = await this.serverStatsRepo
|
||||
.createQueryBuilder('ss')
|
||||
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
|
||||
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
|
||||
.orderBy('ss.recorded_at', 'DESC')
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
if (historicalStats && historicalStats.player_count > 0) {
|
||||
const dropPercent =
|
||||
((historicalStats.player_count - stats.player_count) /
|
||||
historicalStats.player_count) *
|
||||
100;
|
||||
|
||||
if (dropPercent >= config.population_drop_threshold_percent) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'population_drop',
|
||||
'info',
|
||||
'Population Drop Detected',
|
||||
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`,
|
||||
{
|
||||
previous_count: historicalStats.player_count,
|
||||
current_count: stats.player_count,
|
||||
drop_percent: Math.round(dropPercent),
|
||||
threshold_percent: config.population_drop_threshold_percent,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire an alert if cooldown has expired. */
|
||||
private async maybeFireAlert(
|
||||
config: AlertConfig,
|
||||
alertType: string,
|
||||
severity: string,
|
||||
title: string,
|
||||
message: string,
|
||||
metadata: Record<string, any>,
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const cooldownKey = `${config.license_id}:${alertType}`;
|
||||
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
|
||||
|
||||
if (now - lastFired < ALERT_COOLDOWN_MS) {
|
||||
return; // Still in cooldown — skip.
|
||||
}
|
||||
|
||||
this.cooldowns.set(cooldownKey, now);
|
||||
|
||||
const history = this.alertHistoryRepo.create({
|
||||
license_id: config.license_id,
|
||||
alert_type: alertType,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
notified_discord: config.notify_discord,
|
||||
notified_pushbullet: config.notify_pushbullet,
|
||||
notified_email: config.notify_email,
|
||||
});
|
||||
|
||||
await this.alertHistoryRepo.save(history);
|
||||
|
||||
this.logger.log(
|
||||
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||
let config = await this.alertConfigRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
// Create default config if not exists
|
||||
config = this.alertConfigRepo.create({
|
||||
license_id: licenseId,
|
||||
population_drop_enabled: true,
|
||||
|
||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
providers: [SchedulesService, NatsService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
|
||||
@@ -1,21 +1,220 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
private executorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Bootstrap: calculate next_run for any task that has none.
|
||||
this.bootstrapNextRuns().catch(err =>
|
||||
this.logger.error('Failed to bootstrap next_run values', err),
|
||||
);
|
||||
|
||||
// Poll every 60 seconds for due tasks.
|
||||
this.executorInterval = setInterval(() => {
|
||||
this.executeDueTasks().catch(err =>
|
||||
this.logger.error('Schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
this.logger.log('Schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.executorInterval) {
|
||||
clearInterval(this.executorInterval);
|
||||
this.executorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** On startup, stamp next_run on tasks that don't have one yet. */
|
||||
private async bootstrapNextRuns(): Promise<void> {
|
||||
const tasks = await this.taskRepository.find({
|
||||
where: { is_active: true, next_run: null as any },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
if (next) {
|
||||
task.next_run = next;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find all active tasks whose next_run <= now and fire them. */
|
||||
private async executeDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
is_active: true,
|
||||
next_run: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (dueTasks.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
|
||||
|
||||
for (const task of dueTasks) {
|
||||
try {
|
||||
await this.executeTask(task);
|
||||
|
||||
// Advance next_run.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to execute task ${task.id} (${task.task_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
// Still advance next_run so we don't hammer on a broken task.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a single task via NATS based on its task_type. */
|
||||
private async executeTask(task: ScheduledTask): Promise<void> {
|
||||
const { license_id, task_type, task_name, task_config } = task;
|
||||
|
||||
this.logger.log(
|
||||
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
|
||||
);
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
const command = (task_config?.command as string) ?? '';
|
||||
if (!command) {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
|
||||
return await this.taskRepository.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -27,31 +226,23 @@ export class SchedulesService {
|
||||
licenseId: string,
|
||||
dto: CreateTaskDto,
|
||||
): Promise<ScheduledTask> {
|
||||
// Validate cron expression is parseable
|
||||
// In production, you'd use a cron parser library to validate
|
||||
// For now, we rely on the regex in the DTO
|
||||
|
||||
// Set default timezone if not provided
|
||||
const timezone = dto.timezone || 'UTC';
|
||||
const now = new Date();
|
||||
const next = nextCronDate(dto.cron_expression, now);
|
||||
|
||||
const task = this.taskRepository.create({
|
||||
license_id: licenseId,
|
||||
task_type: dto.task_type,
|
||||
task_name: dto.task_name,
|
||||
cron_expression: dto.cron_expression,
|
||||
timezone: timezone,
|
||||
timezone,
|
||||
task_config: dto.task_config || {},
|
||||
is_active: true,
|
||||
next_run: null, // Would be calculated by scheduler
|
||||
created_at: new Date(),
|
||||
next_run: next ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
const saved = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
|
||||
// This would send a NATS message to the scheduler service to register the task
|
||||
|
||||
return saved;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
@@ -70,15 +261,15 @@ export class SchedulesService {
|
||||
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.assign(task, dto);
|
||||
|
||||
const updated = await this.taskRepository.save(task);
|
||||
// Recalculate next_run if the cron expression changed.
|
||||
if (dto.cron_expression) {
|
||||
const next = nextCronDate(dto.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
// TODO: Update task registration with scheduler
|
||||
// Send NATS message to update the task in tokio-cron-scheduler
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async deleteTask(licenseId: string, taskId: string) {
|
||||
@@ -94,10 +285,6 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
await this.taskRepository.delete(taskId);
|
||||
|
||||
// TODO: Unregister task from scheduler
|
||||
// Send NATS message to remove the task from tokio-cron-scheduler
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -114,11 +301,13 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
task.is_active = enabled;
|
||||
const updated = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Enable/disable task in scheduler
|
||||
// Send NATS message to pause or resume the task
|
||||
// When re-enabling, calculate next_run if it's missing.
|
||||
if (enabled && !task.next_run) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
|
||||
import { StoreService } from './store.service';
|
||||
import { Module as ModuleEntity } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
|
||||
controllers: [StoreController],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Module } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Module)
|
||||
private readonly moduleRepo: Repository<Module>,
|
||||
@InjectRepository(ModulePurchase)
|
||||
private readonly purchaseRepo: Repository<ModulePurchase>,
|
||||
@InjectRepository(ModuleInstallation)
|
||||
private readonly installationRepo: Repository<ModuleInstallation>,
|
||||
) {}
|
||||
|
||||
async getCatalog(): Promise<Module[]> {
|
||||
@@ -26,14 +31,19 @@ export class StoreService {
|
||||
order: { purchased_at: 'DESC' },
|
||||
});
|
||||
|
||||
const installations = await this.installationRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
relations: ['module'],
|
||||
});
|
||||
|
||||
return {
|
||||
purchased: purchases,
|
||||
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
|
||||
installed: installations,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
||||
// Check if already purchased
|
||||
// Check if already purchased.
|
||||
const existing = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -50,15 +60,15 @@ export class StoreService {
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`, // Stub
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
}
|
||||
|
||||
async installModule(licenseId: string, moduleId: string) {
|
||||
// Verify purchase exists
|
||||
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
|
||||
// Verify purchase exists.
|
||||
const purchase = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -67,11 +77,44 @@ export class StoreService {
|
||||
throw new ForbiddenException('Module not purchased');
|
||||
}
|
||||
|
||||
// Stub - would create module_installation record
|
||||
return {
|
||||
message: 'Module installed successfully',
|
||||
// Verify module exists.
|
||||
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
|
||||
if (!module) {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Idempotent: return existing installation record if one already exists.
|
||||
const existing = await this.installationRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If previously failed, reset to pending so it can be retried.
|
||||
if (existing.status === 'failed') {
|
||||
existing.status = 'installed';
|
||||
existing.installed_at = new Date();
|
||||
existing.error_message = null;
|
||||
return this.installationRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create installation record and mark as installed.
|
||||
// In a full implementation this would trigger an async deployment pipeline.
|
||||
const installation = this.installationRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
status: 'installed',
|
||||
};
|
||||
installed_at: new Date(),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const saved = await this.installationRepo.save(installation);
|
||||
|
||||
this.logger.log(
|
||||
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,19 +126,112 @@ export class WipesService {
|
||||
would_delete: string[];
|
||||
would_preserve: string[];
|
||||
estimated_duration_seconds: number;
|
||||
profile_name: string | null;
|
||||
notes: string[];
|
||||
}> {
|
||||
// Stub implementation - real logic would analyze wipe profile config
|
||||
const mockResult = {
|
||||
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
|
||||
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
|
||||
estimated_duration_seconds: 45,
|
||||
};
|
||||
|
||||
if (dto.wipe_type === 'full') {
|
||||
mockResult.would_delete.push('oxide/data/*');
|
||||
mockResult.estimated_duration_seconds = 120;
|
||||
// Resolve profile config if a profile ID was supplied.
|
||||
let profile: WipeProfile | null = null;
|
||||
if (dto.wipe_profile_id) {
|
||||
profile = await this.wipeProfileRepo.findOne({
|
||||
where: { id: dto.wipe_profile_id, license_id: licenseId },
|
||||
});
|
||||
}
|
||||
|
||||
return mockResult;
|
||||
if (!profile && dto.wipe_profile_id) {
|
||||
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
|
||||
}
|
||||
|
||||
const notes: string[] = [];
|
||||
|
||||
// Base files affected by all wipe types.
|
||||
const would_delete: string[] = ['*.map', '*.sav'];
|
||||
const would_preserve: string[] = [
|
||||
'oxide/',
|
||||
'oxide/plugins/',
|
||||
'cfg/',
|
||||
'server.cfg',
|
||||
];
|
||||
|
||||
// Blueprint wipe additions.
|
||||
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
|
||||
would_delete.push('player.blueprints.db', 'player.tech.db');
|
||||
}
|
||||
|
||||
// Full wipe: also clear player data and oxide data.
|
||||
if (dto.wipe_type === 'full') {
|
||||
would_delete.push(
|
||||
'player.deaths.db',
|
||||
'player.identities.db',
|
||||
'player.states.db',
|
||||
'player.tokens.db',
|
||||
'oxide/data/*',
|
||||
);
|
||||
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
|
||||
}
|
||||
|
||||
// Factor in pre_wipe_config from the profile (if set).
|
||||
let estimatedSeconds = 45;
|
||||
|
||||
if (profile) {
|
||||
const pre = profile.pre_wipe_config as Record<string, any>;
|
||||
const post = profile.post_wipe_config as Record<string, any>;
|
||||
|
||||
if (pre?.backup_before_wipe) {
|
||||
estimatedSeconds += 60;
|
||||
notes.push('Pre-wipe backup will run before deletion (+60s)');
|
||||
would_preserve.push('backups/');
|
||||
}
|
||||
|
||||
if (pre?.kick_players_before_wipe) {
|
||||
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
|
||||
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
|
||||
if (maxWarning > 0) {
|
||||
estimatedSeconds += maxWarning * 60;
|
||||
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (post?.verify_server_started) {
|
||||
estimatedSeconds += 30;
|
||||
notes.push('Post-wipe: server health check will run (+30s)');
|
||||
}
|
||||
|
||||
if (post?.rollback_on_failure) {
|
||||
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
|
||||
}
|
||||
|
||||
if (post?.max_restart_attempts) {
|
||||
const attempts = post.max_restart_attempts as number;
|
||||
if (attempts > 1) {
|
||||
estimatedSeconds += (attempts - 1) * 15;
|
||||
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notes.push('No profile selected — using default wipe behavior');
|
||||
}
|
||||
|
||||
// Account for world size in time estimate.
|
||||
// Larger worlds take longer to clear from disk (rough heuristic).
|
||||
// We don't have world_size here without querying server_config,
|
||||
// so apply a static estimate per wipe type.
|
||||
if (dto.wipe_type === 'full') {
|
||||
estimatedSeconds += 75;
|
||||
} else if (dto.wipe_type === 'blueprint') {
|
||||
estimatedSeconds += 10;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
|
||||
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
|
||||
);
|
||||
|
||||
return {
|
||||
would_delete,
|
||||
would_preserve,
|
||||
estimated_duration_seconds: estimatedSeconds,
|
||||
profile_name: profile?.profile_name ?? null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user