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 { ThreadsModule } from './threads/threads.module';
|
||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.module';
|
import { InboxModule } from './inbox/inbox.module';
|
||||||
|
import { TemplateModule } from './templates/template.module';
|
||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { NotificationModule } from './notifications/notification.module';
|
import { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
@@ -37,6 +38,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
|
TemplateModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
SupportModule,
|
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 {
|
function sender(): TemplatedSender {
|
||||||
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const templates = new TemplateService(new TemplateRepository(asService));
|
const templates = new TemplateService(new TemplateRepository(asService));
|
||||||
return new TemplatedSender(templates, outbound);
|
return new TemplatedSender(templates, outbound, makeFakePorts());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedReceipt() {
|
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 { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
import { TemplateService } from './template.service';
|
import { TemplateService } from './template.service';
|
||||||
import type { RenderedContent } from './template.renderer';
|
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. */
|
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
|
||||||
export type ExternalChannel = 'EMAIL' | 'SMS';
|
export type ExternalChannel = 'EMAIL' | 'SMS';
|
||||||
@@ -28,9 +31,15 @@ export class TemplatedSender {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly templates: TemplateService,
|
private readonly templates: TemplateService,
|
||||||
private readonly outbound: OutboundService,
|
private readonly outbound: OutboundService,
|
||||||
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendTemplated(input: SendTemplatedInput) {
|
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, {
|
const { content, provenance } = await this.templates.render(input.source, input.vars, {
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
locale: input.locale,
|
locale: input.locale,
|
||||||
|
|||||||
Reference in New Issue
Block a user