diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 29129e2..a1345a2 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -10,6 +10,7 @@ import { OutboxModule } from './outbox/outbox.module'; import { ThreadsModule } from './threads/threads.module'; import { MessageModule } from './messaging/message.module'; import { InboxModule } from './inbox/inbox.module'; +import { TemplateModule } from './templates/template.module'; import { MediaModule } from './media/media.module'; import { NotificationModule } from './notifications/notification.module'; import { SupportModule } from './support/support.module'; @@ -37,6 +38,7 @@ import { DevController } from './dev/dev.controller'; ThreadsModule, MessageModule, InboxModule, + TemplateModule, MediaModule, NotificationModule, SupportModule, diff --git a/packages/iios-service/src/templates/template.controller.ts b/packages/iios-service/src/templates/template.controller.ts new file mode 100644 index 0000000..bfa5bda --- /dev/null +++ b/packages/iios-service/src/templates/template.controller.ts @@ -0,0 +1,62 @@ +import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common'; +import { IiosTemplateChannel } from '@prisma/client'; +import { SessionVerifier } from '../platform/session.verifier'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { TemplatedSender, type ExternalChannel } from './templated-sender'; +import { TemplateService } from './template.service'; +import { PreviewTemplateDto, SendTemplateDto } from './template.dto'; +import type { TemplateSource } from './template.model'; + +@Controller('v1/templates') +export class TemplateController { + constructor( + private readonly sender: TemplatedSender, + private readonly templates: TemplateService, + private readonly session: SessionVerifier, + private readonly actors: ActorResolver, + ) {} + + /** Render a stored/inline template and queue it for external delivery (EMAIL/SMS). */ + @Post('send') + async send(@Body() body: SendTemplateDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + const scope = await this.actors.resolveScope(principal); + return this.sender.sendTemplated({ + source: this.source(body), + channel: body.channel as ExternalChannel, + target: body.target, + vars: body.vars ?? {}, + scopeId: scope.id, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + ...(body.purpose ? { purpose: body.purpose } : {}), + }); + } + + /** Render only — no send. For marketing/QA to eyeball copy before it goes out. */ + @Post('preview') + async preview(@Body() body: PreviewTemplateDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + const scope = await this.actors.resolveScope(principal); + const { content } = await this.templates.render(this.source(body), body.vars ?? {}, { + channel: body.channel as IiosTemplateChannel, + ...(body.locale ? { locale: body.locale } : {}), + scopeId: scope.id, + }); + return content; + } + + /** Map the DTO's key|inline into a TemplateSource (exactly one must be present). */ + private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource { + if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both'); + if (body.inline) return { inline: body.inline }; + if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key }; + throw new BadRequestException('one of "key" or "inline" is required'); + } + + 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/templates/template.dto.ts b/packages/iios-service/src/templates/template.dto.ts new file mode 100644 index 0000000..cd14284 --- /dev/null +++ b/packages/iios-service/src/templates/template.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +const EXTERNAL_CHANNELS = ['EMAIL', 'SMS'] as const; +const ALL_CHANNELS = ['EMAIL', 'SMS', 'INTERNAL'] as const; + +/** Inline template content — an ad-hoc source (e.g. finished HTML from marketing). */ +export class InlineTemplateDto { + @IsOptional() @IsString() subject?: string; + @IsOptional() @IsString() html?: string; + @IsOptional() @IsString() text?: string; + @IsOptional() @IsString({ each: true }) variables?: string[]; +} + +/** Body of POST /v1/templates/send. Provide EITHER `key` (stored) OR `inline` (ad-hoc). */ +export class SendTemplateDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsIn(EXTERNAL_CHANNELS) channel!: (typeof EXTERNAL_CHANNELS)[number]; + @IsString() @IsNotEmpty() target!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; + @IsOptional() @IsString() purpose?: string; +} + +/** Body of POST /v1/templates/preview — render only, no send. INTERNAL allowed (preview only). */ +export class PreviewTemplateDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsIn(ALL_CHANNELS) channel!: (typeof ALL_CHANNELS)[number]; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; +} diff --git a/packages/iios-service/src/templates/template.module.ts b/packages/iios-service/src/templates/template.module.ts new file mode 100644 index 0000000..d8e13e0 --- /dev/null +++ b/packages/iios-service/src/templates/template.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AdaptersModule } from '../adapters/adapters.module'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplatedSender } from './templated-sender'; +import { TemplateSeeder } from './template.seeder'; +import { TemplateController } from './template.controller'; + +/** + * Reusable message-template module: render a stored/inline template → hand it to the existing + * outbound pipeline (via AdaptersModule's OutboundService). Content lives here; delivery stays in + * the outbound/capability layers. SessionVerifier, ActorResolver (IdentityModule) and PLATFORM_PORTS + * (PlatformModule) are global. + */ +@Module({ + imports: [AdaptersModule], + controllers: [TemplateController], + providers: [TemplateRepository, TemplateService, TemplatedSender, TemplateSeeder], + exports: [TemplateService, TemplatedSender], +}) +export class TemplateModule {} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts index 4c05745..b1f52e2 100644 --- a/packages/iios-service/src/templates/templated-sender.spec.ts +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -18,7 +18,7 @@ const asService = prisma as unknown as PrismaService; function sender(): TemplatedSender { const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); const templates = new TemplateService(new TemplateRepository(asService)); - return new TemplatedSender(templates, outbound); + return new TemplatedSender(templates, outbound, makeFakePorts()); } async function seedReceipt() { diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts index 4b4373f..b7a96b6 100644 --- a/packages/iios-service/src/templates/templated-sender.ts +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -1,8 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import type { IiosPlatformPorts } from '@insignia/iios-contracts'; import { OutboundService } from '../adapters/outbound.service'; +import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { decideOrThrow } from '../platform/fail-closed'; import { TemplateService } from './template.service'; import type { RenderedContent } from './template.renderer'; -import type { TemplateSource } from './template.model'; +import { isInlineSource, type TemplateSource } from './template.model'; /** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ export type ExternalChannel = 'EMAIL' | 'SMS'; @@ -28,9 +31,15 @@ export class TemplatedSender { constructor( private readonly templates: TemplateService, private readonly outbound: OutboundService, + @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, ) {} async sendTemplated(input: SendTemplatedInput) { + // Inline HTML to a customer is more dangerous than a reviewed stored template — gate it apart, + // so policy can permit stored sends while restricting ad-hoc ones. Fail-closed. + const action = isInlineSource(input.source) ? 'iios.template.send.inline' : 'iios.template.send'; + await decideOrThrow(this.ports, { action, scopeId: input.scopeId, channel: input.channel }); + const { content, provenance } = await this.templates.render(input.source, input.vars, { channel: input.channel, locale: input.locale,