feat(service): P6.3 route REST + approve/deny → execute to sandbox

RouteService (createBinding/listBindings/simulate/listDecisions/approve/deny/
execute/flushDigest); approve REVIEW→ALLOW + route.approved event → execute via
P5 OutboundService (sandbox, idempotent per decision); deny never executes.
7 route endpoints; PORTAL adapter registered. 5 route tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:40:08 +05:30
parent 7ed3c11891
commit 968a90bdb9
7 changed files with 268 additions and 3 deletions
@@ -28,7 +28,7 @@ export class AdapterRegistry {
orgId: process.env.ADAPTER_ORG ?? 'org_portal-demo',
appId: process.env.ADAPTER_APP ?? 'portal-demo',
};
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP']) {
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
this.adapters.set(channelType, {
adapter: new WebhookAdapter(channelType),
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
+2
View File
@@ -9,6 +9,7 @@ import { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module';
import { SupportModule } from './support/support.module';
import { AdaptersModule } from './adapters/adapters.module';
import { RoutingModule } from './routing/routing.module';
import { HealthController } from './health.controller';
import { DevController } from './dev/dev.controller';
@@ -24,6 +25,7 @@ import { DevController } from './dev/dev.controller';
InboxModule,
SupportModule,
AdaptersModule,
RoutingModule,
],
controllers: [HealthController, DevController],
})
@@ -0,0 +1,62 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Query } from '@nestjs/common';
import { IiosOutputFormat, IiosRouteMode } from '@prisma/client';
import { RouteService } from './route.service';
import { SessionVerifier } from '../platform/session.verifier';
import type { MessagePrincipal } from '../identity/actor.resolver';
import { CreateBindingDto, SimulateDto } from './route.dto';
@Controller('v1/routes')
export class RouteController {
constructor(
private readonly routes: RouteService,
private readonly session: SessionVerifier,
) {}
@Post('bindings')
async createBinding(@Body() body: CreateBindingDto, @Headers('authorization') auth?: string) {
return this.routes.createBinding(this.principal(auth), {
...body,
mode: body.mode as IiosRouteMode | undefined,
outputFormat: body.outputFormat as IiosOutputFormat | undefined,
});
}
@Get('bindings')
async listBindings(@Headers('authorization') auth?: string) {
return this.routes.listBindings(this.principal(auth));
}
@Post('simulate')
async simulate(@Body() body: SimulateDto, @Headers('authorization') auth?: string) {
this.principal(auth);
return this.routes.simulate(body.interactionId, { channelType: body.originChannelType, ref: body.originRef });
}
@Get('decisions')
async decisions(@Query('state') state?: string, @Headers('authorization') auth?: string) {
this.principal(auth);
return this.routes.listDecisions({ state });
}
@Post('decisions/:id/approve')
async approve(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.routes.approve(id, this.principal(auth));
}
@Post('decisions/:id/deny')
async deny(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.routes.deny(id, this.principal(auth));
}
@Post('bindings/:id/flush-digest')
async flushDigest(@Param('id') id: string, @Headers('authorization') auth?: string) {
this.principal(auth);
return this.routes.flushDigest(id);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,24 @@
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
const MODES = ['MANUAL', 'AUTOMATIC', 'HYBRID', 'SIMULATION_ONLY'] as const;
const FORMATS = ['FORWARD', 'THREADED', 'DIGEST', 'SUMMARY', 'TRANSCRIPT'] as const;
export class CreateBindingDto {
@IsString() originChannelType!: string;
@IsOptional() @IsString() originRef?: string;
@IsString() destinationChannelType!: string;
@IsOptional() @IsString() destinationRef?: string;
@IsOptional() @IsString() restrictionProfile?: string;
@IsOptional() @IsIn(MODES) mode?: (typeof MODES)[number];
@IsOptional() @IsIn(FORMATS) outputFormat?: (typeof FORMATS)[number];
@IsOptional() @IsString() frequency?: string;
@IsOptional() @IsBoolean() includeAttachments?: boolean;
@IsOptional() @IsBoolean() requiresReview?: boolean;
@IsOptional() @IsBoolean() enabled?: boolean;
}
export class SimulateDto {
@IsString() interactionId!: string;
@IsString() originChannelType!: string;
@IsOptional() @IsString() originRef?: string;
}
@@ -0,0 +1,133 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { RouteSimulator, type OriginRef } from './route-simulator';
import { OutboundService } from '../adapters/outbound.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
export interface CreateBindingInput {
originChannelType: string;
originRef?: string;
destinationChannelType: string;
destinationRef?: string;
restrictionProfile?: string;
mode?: IiosRouteMode;
outputFormat?: IiosOutputFormat;
frequency?: string;
includeAttachments?: boolean;
requiresReview?: boolean;
enabled?: boolean;
}
@Injectable()
export class RouteService {
constructor(
private readonly prisma: PrismaService,
private readonly simulator: RouteSimulator,
private readonly outbound: OutboundService,
private readonly actors: ActorResolver,
) {}
async createBinding(principal: MessagePrincipal, input: CreateBindingInput) {
const scope = await this.actors.resolveScope(principal);
return this.prisma.iiosRouteBinding.create({ data: { ...input, scopeId: scope.id } });
}
async listBindings(principal: MessagePrincipal) {
const scope = await this.actors.resolveScope(principal);
return this.prisma.iiosRouteBinding.findMany({ where: { scopeId: scope.id }, orderBy: { createdAt: 'desc' } });
}
simulate(interactionId: string, origin: OriginRef) {
return this.simulator.simulate(interactionId, origin);
}
async listDecisions(opts: { state?: string } = {}) {
return this.prisma.iiosRouteDecision.findMany({
where: opts.state ? { decisionState: opts.state as IiosRouteDecision['decisionState'] } : {},
orderBy: { createdAt: 'desc' },
take: 100,
include: { routeBinding: true },
});
}
async approve(decisionId: string, principal: MessagePrincipal) {
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
if (!decision) throw new NotFoundException('route decision not found');
if (decision.decisionState === 'DENY' || decision.decisionState === 'SUPPRESS') {
throw new BadRequestException(`cannot approve a ${decision.decisionState} decision`);
}
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal);
const updated = await this.prisma.iiosRouteDecision.update({
where: { id: decisionId },
data: { decisionState: 'ALLOW', decidedByActorId: actor.id },
});
await this.emitApproved(decision.routeBinding.scopeId, decisionId);
await this.execute(updated, decision.routeBinding);
return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } });
}
async deny(decisionId: string, principal: MessagePrincipal) {
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
if (!decision) throw new NotFoundException('route decision not found');
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal);
return this.prisma.iiosRouteDecision.update({
where: { id: decisionId },
data: { decisionState: 'DENY', decidedByActorId: actor.id },
});
}
/** Dispatch an ALLOW decision to the P5 sandbox (no real network). Idempotent. */
async execute(decision: IiosRouteDecision, binding: IiosRouteBinding): Promise<void> {
if (decision.executed || decision.decisionState !== 'ALLOW') return;
const preview = decision.previewPayload as { text?: string };
const cmd = await this.outbound.send(
binding.destinationChannelType,
binding.destinationRef ?? 'default',
{ text: preview.text ?? '' },
`route:${decision.id}`,
);
await this.prisma.iiosRouteDecision.update({ where: { id: decision.id }, data: { executed: true, executedCommandId: cmd.id } });
}
/** Digest window MVP: batch this binding's un-executed ALLOW digest decisions into one forward. */
async flushDigest(bindingId: string): Promise<{ forwarded: number }> {
const binding = await this.prisma.iiosRouteBinding.findUniqueOrThrow({ where: { id: bindingId } });
const pending = await this.prisma.iiosRouteDecision.findMany({
where: { routeBindingId: bindingId, outputFormat: 'DIGEST', decisionState: 'ALLOW', executed: false },
orderBy: { createdAt: 'asc' },
});
if (pending.length === 0) return { forwarded: 0 };
const digest = pending.map((d) => (d.previewPayload as { text?: string }).text ?? '').join('\n');
const cmd = await this.outbound.send(binding.destinationChannelType, binding.destinationRef ?? 'default', { text: `[digest x${pending.length}]\n${digest}` }, `digest:${bindingId}:${pending[pending.length - 1]!.id}`);
await this.prisma.iiosRouteDecision.updateMany({
where: { id: { in: pending.map((d) => d.id) } },
data: { executed: true, executedCommandId: cmd.id },
});
return { forwarded: pending.length };
}
private async emitApproved(scopeId: string, decisionId: string): Promise<void> {
const event: CloudEvent = {
specversion: '1.0',
id: `evt_routeapproved_${decisionId}`,
type: IIOS_EVENTS.routeApproved,
source: `iios/routing/${scopeId}`,
subject: `route_decision/${decisionId}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: scopeId, idempotencyKey: `routeapproved:${decisionId}`, dataClass: 'internal' },
data: { decisionId },
};
await this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'route_decision',
aggregateId: decisionId,
eventType: IIOS_EVENTS.routeApproved,
cloudEvent: event as unknown as Prisma.InputJsonValue,
partitionKey: `${scopeId}:${decisionId}`,
},
});
}
}
@@ -7,6 +7,9 @@ import { MessageService, type MessagePrincipal } from '../messaging/message.serv
import { ActorResolver } from '../identity/actor.resolver';
import { RuleEngine } from './rule-engine';
import { RouteSimulator } from './route-simulator';
import { RouteService } from './route.service';
import { OutboundService } from '../adapters/outbound.service';
import { AdapterRegistry } from '../adapters/adapter.registry';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
@@ -93,3 +96,39 @@ describe('Route simulator (P6, preview-first)', () => {
expect(first.decisions[0]?.reasonCodes).toEqual(second.decisions[0]?.reasonCodes);
});
});
describe('RouteService approve/deny → execute (P6)', () => {
const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' };
const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new AdapterRegistry()), actors);
it('approving a REVIEW decision executes the forward to the sandbox', async () => {
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
await makeBinding(scopeId, 'seniors', 'SENIORS');
const service = svc();
await service.simulate(interactionId, ORIGIN);
const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'seniors' } });
const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({
where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } },
});
expect(decision.decisionState).toBe('REVIEW');
const approved = await service.approve(decision.id, admin);
expect(approved.decisionState).toBe('ALLOW');
expect(approved.executed).toBe(true);
expect(await prisma.iiosOutboundCommand.count()).toBe(1); // dispatched to sandbox
});
it('denying a decision never executes', async () => {
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
await makeBinding(scopeId, 'children', 'CHILD');
const service = svc();
await service.simulate(interactionId, ORIGIN);
const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'children' } });
const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({
where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } },
});
// already DENY; approving is rejected, and nothing is sent
await expect(service.approve(decision.id, admin)).rejects.toThrow();
expect(await prisma.iiosOutboundCommand.count()).toBe(0);
});
});
@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { AdaptersModule } from '../adapters/adapters.module';
import { RuleEngine } from './rule-engine';
import { RouteSimulator } from './route-simulator';
import { RouteService } from './route.service';
import { RouteController } from './route.controller';
@Module({
providers: [RuleEngine, RouteSimulator],
exports: [RuleEngine, RouteSimulator],
imports: [AdaptersModule],
controllers: [RouteController],
providers: [RuleEngine, RouteSimulator, RouteService],
exports: [RuleEngine, RouteSimulator, RouteService],
})
export class RoutingModule {}