diff --git a/packages/iios-service/src/adapters/adapter.registry.ts b/packages/iios-service/src/adapters/adapter.registry.ts index 5afecb6..5ce5e93 100644 --- a/packages/iios-service/src/adapters/adapter.registry.ts +++ b/packages/iios-service/src/adapters/adapter.registry.ts @@ -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 }, diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 65a26ab..18034a4 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -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], }) diff --git a/packages/iios-service/src/routing/route.controller.ts b/packages/iios-service/src/routing/route.controller.ts new file mode 100644 index 0000000..85cc87c --- /dev/null +++ b/packages/iios-service/src/routing/route.controller.ts @@ -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); + } +} diff --git a/packages/iios-service/src/routing/route.dto.ts b/packages/iios-service/src/routing/route.dto.ts new file mode 100644 index 0000000..763d25e --- /dev/null +++ b/packages/iios-service/src/routing/route.dto.ts @@ -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; +} diff --git a/packages/iios-service/src/routing/route.service.ts b/packages/iios-service/src/routing/route.service.ts new file mode 100644 index 0000000..c494615 --- /dev/null +++ b/packages/iios-service/src/routing/route.service.ts @@ -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 { + 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 { + 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}`, + }, + }); + } +} diff --git a/packages/iios-service/src/routing/route.spec.ts b/packages/iios-service/src/routing/route.spec.ts index d86933c..e93dd0a 100644 --- a/packages/iios-service/src/routing/route.spec.ts +++ b/packages/iios-service/src/routing/route.spec.ts @@ -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); + }); +}); diff --git a/packages/iios-service/src/routing/routing.module.ts b/packages/iios-service/src/routing/routing.module.ts index 7160602..5603191 100644 --- a/packages/iios-service/src/routing/routing.module.ts +++ b/packages/iios-service/src/routing/routing.module.ts @@ -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 {}