Feat/s3 storage #1

Merged
maaz519 merged 14 commits from feat/s3-storage into dev 2026-07-18 09:06:40 +00:00
6 changed files with 135 additions and 3 deletions
Showing only changes of commit b44f795ba4 - Show all commits
+2
View File
@@ -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,