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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user