164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
import { Controller, Get, Post, Put, Patch, Param, Body, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
|
import { StandingOrderRepository } from '../../../infrastructure/repositories/standing-order.repository';
|
|
import { StandingOrderExecutionRepository } from '../../../infrastructure/repositories/standing-order-execution.repository';
|
|
import { StandingOrder } from '../../../domain/entities/standing-order.entity';
|
|
import { TenantContextService } from '@it0/common';
|
|
import * as crypto from 'crypto';
|
|
|
|
@Controller('api/v1/ops/standing-orders')
|
|
export class StandingOrderController {
|
|
constructor(
|
|
private readonly standingOrderRepository: StandingOrderRepository,
|
|
private readonly executionRepository: StandingOrderExecutionRepository,
|
|
) {}
|
|
|
|
@Get()
|
|
async listOrders() {
|
|
const orders = await this.standingOrderRepository.findAll();
|
|
return orders.map((o) => this.serializeOrder(o));
|
|
}
|
|
|
|
/**
|
|
* Maps incoming body from either web-admin simplified format or internal API format
|
|
* to the StandingOrder entity fields.
|
|
*/
|
|
private mapBodyToOrder(order: StandingOrder, body: any): void {
|
|
if (body.name !== undefined) order.name = body.name;
|
|
if (body.description !== undefined) order.description = body.description;
|
|
if (body.definedInSessionId !== undefined) order.definedInSessionId = body.definedInSessionId;
|
|
if (body.escalationPolicyId !== undefined) order.escalationPolicyId = body.escalationPolicyId;
|
|
|
|
// Trigger: accept both { trigger: { type, ... } } and { triggerConfig: { triggerType, ... } }
|
|
if (body.trigger) {
|
|
order.triggerType = body.trigger.type ?? 'manual';
|
|
order.trigger = body.trigger;
|
|
} else if (body.triggerConfig) {
|
|
const tc = body.triggerConfig;
|
|
order.triggerType = tc.triggerType ?? 'manual';
|
|
order.trigger = {
|
|
type: tc.triggerType ?? 'manual',
|
|
cronExpression: tc.cronExpression,
|
|
eventType: tc.eventType,
|
|
thresholdCondition: tc.metric
|
|
? { metricType: tc.metric, operator: '>=', value: tc.threshold ?? 0, durationSeconds: 60 }
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// Targets: accept both { targets: { serverIds } } and { targetServers: string[] }
|
|
if (body.targets) {
|
|
order.targets = body.targets;
|
|
} else if (body.targetServers) {
|
|
order.targets = { serverIds: body.targetServers };
|
|
}
|
|
|
|
// Agent instructions: accept nested, flat, or simplified format
|
|
const ai = body.agentInstructions;
|
|
order.agentPrompt = ai?.prompt ?? body.agentPrompt ?? body.agentInstruction ?? order.agentPrompt ?? '';
|
|
order.agentSkills = ai?.skills ?? body.agentSkills ?? order.agentSkills;
|
|
order.runbookId = ai?.runbookId ?? body.runbookId ?? order.runbookId;
|
|
order.maxRiskLevel = ai?.maxRiskLevel ?? body.maxRiskLevel ?? order.maxRiskLevel ?? 0;
|
|
order.maxTurns = ai?.maxTurns ?? body.maxTurns ?? order.maxTurns ?? 20;
|
|
order.maxBudgetUsd = ai?.maxBudgetUsd ?? body.maxBudgetUsd ?? body.maxBudget ?? order.maxBudgetUsd;
|
|
|
|
// Decision boundary: accept full or simplified
|
|
if (body.decisionBoundary) {
|
|
order.decisionBoundary = body.decisionBoundary;
|
|
} else if (body.escalateOnFailure !== undefined) {
|
|
order.decisionBoundary = order.decisionBoundary ?? { allowedActions: [], escalateConditions: [], escalationRules: [] };
|
|
order.decisionBoundary.escalateConditions = body.escalateOnFailure ? ['on_failure'] : [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a serialized order with additional web-admin friendly aliases.
|
|
*/
|
|
private serializeOrder(order: StandingOrder) {
|
|
return {
|
|
...order,
|
|
// Web admin aliases
|
|
triggerConfig: {
|
|
triggerType: order.trigger?.type ?? order.triggerType,
|
|
cronExpression: order.trigger?.cronExpression,
|
|
eventType: order.trigger?.eventType,
|
|
metric: order.trigger?.thresholdCondition?.metricType,
|
|
threshold: order.trigger?.thresholdCondition?.value,
|
|
},
|
|
targetServers: order.targets?.serverIds ?? [],
|
|
agentInstruction: order.agentPrompt,
|
|
maxBudget: order.maxBudgetUsd ?? 0,
|
|
escalateOnFailure: order.decisionBoundary?.escalateConditions?.includes('on_failure') ?? false,
|
|
};
|
|
}
|
|
|
|
@Post()
|
|
async createOrder(@Body() body: any) {
|
|
// Quota enforcement
|
|
const tenant = TenantContextService.getTenant();
|
|
if (tenant && tenant.maxStandingOrders !== -1) {
|
|
const existing = await this.standingOrderRepository.findAll();
|
|
const activeCount = existing.filter((o) => o.status === 'active').length;
|
|
if (activeCount >= tenant.maxStandingOrders) {
|
|
throw new HttpException(
|
|
{ message: `Standing order quota exceeded (${activeCount}/${tenant.maxStandingOrders}). Upgrade your plan to create more.`, code: 'QUOTA_EXCEEDED' },
|
|
HttpStatus.TOO_MANY_REQUESTS,
|
|
);
|
|
}
|
|
}
|
|
|
|
const order = new StandingOrder();
|
|
order.id = crypto.randomUUID();
|
|
order.tenantId = body.tenantId;
|
|
order.status = 'active';
|
|
order.validFrom = body.validFrom ? new Date(body.validFrom) : new Date();
|
|
order.validUntil = body.validUntil ? new Date(body.validUntil) : undefined;
|
|
order.createdBy = body.createdBy;
|
|
order.createdAt = new Date();
|
|
order.updatedAt = new Date();
|
|
this.mapBodyToOrder(order, body);
|
|
const saved = await this.standingOrderRepository.save(order);
|
|
return this.serializeOrder(saved);
|
|
}
|
|
|
|
@Get(':id')
|
|
async getOrder(@Param('id') id: string) {
|
|
const order = await this.standingOrderRepository.findById(id);
|
|
if (!order) {
|
|
throw new NotFoundException(`Standing order ${id} not found`);
|
|
}
|
|
return this.serializeOrder(order);
|
|
}
|
|
|
|
@Put(':id')
|
|
async updateOrder(@Param('id') id: string, @Body() body: any) {
|
|
const order = await this.standingOrderRepository.findById(id);
|
|
if (!order) {
|
|
throw new NotFoundException(`Standing order ${id} not found`);
|
|
}
|
|
this.mapBodyToOrder(order, body);
|
|
order.updatedAt = new Date();
|
|
const saved = await this.standingOrderRepository.save(order);
|
|
return this.serializeOrder(saved);
|
|
}
|
|
|
|
@Patch(':id/status')
|
|
async updateStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
|
const order = await this.standingOrderRepository.findById(id);
|
|
if (!order) {
|
|
throw new NotFoundException(`Standing order ${id} not found`);
|
|
}
|
|
order.status = body.status as StandingOrder['status'];
|
|
order.updatedAt = new Date();
|
|
return this.standingOrderRepository.save(order);
|
|
}
|
|
|
|
@Get(':id/executions')
|
|
async getExecutions(@Param('id') id: string) {
|
|
const order = await this.standingOrderRepository.findById(id);
|
|
if (!order) {
|
|
throw new NotFoundException(`Standing order ${id} not found`);
|
|
}
|
|
return this.executionRepository.findByOrderId(id);
|
|
}
|
|
}
|