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:
2026-07-18 12:39:04 +05:30
parent 37407cb0af
commit b44f795ba4
6 changed files with 135 additions and 3 deletions
@@ -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;
}