b44f795ba4
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>
63 lines
2.9 KiB
TypeScript
63 lines
2.9 KiB
TypeScript
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);
|
|
}
|
|
}
|