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:
@@ -28,7 +28,7 @@ export class AdapterRegistry {
|
|||||||
orgId: process.env.ADAPTER_ORG ?? 'org_portal-demo',
|
orgId: process.env.ADAPTER_ORG ?? 'org_portal-demo',
|
||||||
appId: process.env.ADAPTER_APP ?? '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, {
|
this.adapters.set(channelType, {
|
||||||
adapter: new WebhookAdapter(channelType),
|
adapter: new WebhookAdapter(channelType),
|
||||||
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MessageModule } from './messaging/message.module';
|
|||||||
import { InboxModule } from './inbox/inbox.module';
|
import { InboxModule } from './inbox/inbox.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
import { AdaptersModule } from './adapters/adapters.module';
|
import { AdaptersModule } from './adapters/adapters.module';
|
||||||
|
import { RoutingModule } from './routing/routing.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { DevController } from './dev/dev.controller';
|
import { DevController } from './dev/dev.controller';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
InboxModule,
|
InboxModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
AdaptersModule,
|
AdaptersModule,
|
||||||
|
RoutingModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, DevController],
|
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 { ActorResolver } from '../identity/actor.resolver';
|
||||||
import { RuleEngine } from './rule-engine';
|
import { RuleEngine } from './rule-engine';
|
||||||
import { RouteSimulator } from './route-simulator';
|
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';
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
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);
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { AdaptersModule } from '../adapters/adapters.module';
|
||||||
import { RuleEngine } from './rule-engine';
|
import { RuleEngine } from './rule-engine';
|
||||||
import { RouteSimulator } from './route-simulator';
|
import { RouteSimulator } from './route-simulator';
|
||||||
|
import { RouteService } from './route.service';
|
||||||
|
import { RouteController } from './route.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [RuleEngine, RouteSimulator],
|
imports: [AdaptersModule],
|
||||||
exports: [RuleEngine, RouteSimulator],
|
controllers: [RouteController],
|
||||||
|
providers: [RuleEngine, RouteSimulator, RouteService],
|
||||||
|
exports: [RuleEngine, RouteSimulator, RouteService],
|
||||||
})
|
})
|
||||||
export class RoutingModule {}
|
export class RoutingModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user