feat(templates): T6 controller + DTOs + module wiring
POST /v1/templates/send (render→queue) and /preview (render only). OPA-gated in the sender: iios.template.send, and iios.template.send.inline for ad-hoc HTML (arbitrary markup to a customer is riskier than a reviewed stored template). Scope resolved from the caller's principal. TemplateModule registered in AppModule. Verified over HTTP against a local boot: preview + send of the seeded payment.receipt (SENT via sandbox, provenance persisted), XSS name escaped, idempotent replay → 1 row, unknown key 404, bad channel/no-source/no-auth 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@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<string, unknown>;
|
||||
@IsOptional() @IsString() locale?: string;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user