Feat/s3 storage #2
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- Template provenance on the outbound command: which template (key/version/locale) produced this
|
||||||
|
-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`;
|
||||||
|
-- these columns answer "which template version produced this send?" for replay/audit. All nullable —
|
||||||
|
-- non-templated sends leave them null.
|
||||||
|
ALTER TABLE "IiosOutboundCommand"
|
||||||
|
ADD COLUMN "templateKey" TEXT,
|
||||||
|
ADD COLUMN "templateVersion" INTEGER,
|
||||||
|
ADD COLUMN "templateLocale" TEXT,
|
||||||
|
ADD COLUMN "renderedHash" TEXT;
|
||||||
@@ -868,6 +868,11 @@ model IiosOutboundCommand {
|
|||||||
idempotencyKey String @unique
|
idempotencyKey String @unique
|
||||||
providerRef String?
|
providerRef String?
|
||||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
||||||
|
// Template provenance (which source produced this send) — null for non-templated sends.
|
||||||
|
templateKey String?
|
||||||
|
templateVersion Int?
|
||||||
|
templateLocale String?
|
||||||
|
renderedHash String? // sha256 of the rendered content, for replay/audit
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
attempts IiosDeliveryAttempt[]
|
attempts IiosDeliveryAttempt[]
|
||||||
|
|||||||
@@ -34,8 +34,18 @@ export class OutboundService {
|
|||||||
idempotencyKey?: string,
|
idempotencyKey?: string,
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
purpose?: string,
|
purpose?: string,
|
||||||
|
/** Opaque template provenance recorded on the command. OutboundService neither renders nor
|
||||||
|
* resolves templates — it only persists the four strings it is handed (guaranteed by the
|
||||||
|
* templated sender, never by caller convention). */
|
||||||
|
provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null },
|
||||||
) {
|
) {
|
||||||
const key = idempotencyKey ?? randomUUID();
|
const key = idempotencyKey ?? randomUUID();
|
||||||
|
const prov = {
|
||||||
|
templateKey: provenance?.templateKey ?? null,
|
||||||
|
templateVersion: provenance?.templateVersion ?? null,
|
||||||
|
templateLocale: provenance?.templateLocale ?? null,
|
||||||
|
renderedHash: provenance?.renderedHash ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
||||||
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||||
@@ -46,14 +56,14 @@ export class OutboundService {
|
|||||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
|
||||||
});
|
});
|
||||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SandboxProvider } from './sandbox.provider';
|
|||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
import { EmailProvider } from './email.provider';
|
import { EmailProvider } from './email.provider';
|
||||||
|
|
||||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
|
import { TemplateRepository } from './template.repository';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { TemplatedSender } from './templated-sender';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
|
||||||
|
function sender(): TemplatedSender {
|
||||||
|
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
|
const templates = new TemplateService(new TemplateRepository(asService));
|
||||||
|
return new TemplatedSender(templates, outbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedReceipt() {
|
||||||
|
await prisma.iiosMessageTemplate.create({
|
||||||
|
data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '<p>{{amount}}</p>', bodyText: '{{amount}}', variables: ['firstName', 'amount'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('TemplatedSender.sendTemplated', () => {
|
||||||
|
it('renders a stored template, queues an outbound command, and stamps provenance', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
|
||||||
|
vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1',
|
||||||
|
});
|
||||||
|
expect(cmd.channelType).toBe('EMAIL');
|
||||||
|
expect(cmd.target).toBe('dana@acme.com');
|
||||||
|
expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana');
|
||||||
|
expect(cmd.templateKey).toBe('payment.receipt');
|
||||||
|
expect(cmd.templateVersion).toBe(1);
|
||||||
|
expect(cmd.templateLocale).toBe('en');
|
||||||
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent per key: a replay yields ONE command', async () => {
|
||||||
|
await seedReceipt();
|
||||||
|
const s = sender();
|
||||||
|
const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' };
|
||||||
|
const a = await s.sendTemplated(input);
|
||||||
|
const b = await s.sendTemplated(input);
|
||||||
|
expect(b.id).toBe(a.id);
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends inline content with null template key but a hash', async () => {
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { inline: { subject: 'Hi {{n}}', html: '<b>{{n}}</b>', variables: ['n'] } },
|
||||||
|
channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1',
|
||||||
|
});
|
||||||
|
expect((cmd.payload as { subject: string }).subject).toBe('Hi Z');
|
||||||
|
expect(cmd.templateKey).toBeNull();
|
||||||
|
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shapes an SMS send as text only', async () => {
|
||||||
|
await prisma.iiosMessageTemplate.create({
|
||||||
|
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
|
||||||
|
});
|
||||||
|
const cmd = await sender().sendTemplated({
|
||||||
|
source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1',
|
||||||
|
});
|
||||||
|
expect(cmd.channelType).toBe('SMS');
|
||||||
|
expect(cmd.payload).toEqual({ text: 'Paid $2,000' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import type { RenderedContent } from './template.renderer';
|
||||||
|
import type { TemplateSource } from './template.model';
|
||||||
|
|
||||||
|
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
|
||||||
|
export type ExternalChannel = 'EMAIL' | 'SMS';
|
||||||
|
|
||||||
|
export interface SendTemplatedInput {
|
||||||
|
source: TemplateSource;
|
||||||
|
channel: ExternalChannel;
|
||||||
|
target: string; // email address / phone number
|
||||||
|
vars: Record<string, unknown>;
|
||||||
|
scopeId?: string;
|
||||||
|
locale?: string;
|
||||||
|
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
||||||
|
purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single entry point for a templated external send: render → OutboundService.send → provenance
|
||||||
|
* stamped in one place (by construction, not caller convention). Rendering stays pure/testable;
|
||||||
|
* OutboundService stays content-agnostic (it only records the provenance it is handed).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TemplatedSender {
|
||||||
|
constructor(
|
||||||
|
private readonly templates: TemplateService,
|
||||||
|
private readonly outbound: OutboundService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendTemplated(input: SendTemplatedInput) {
|
||||||
|
const { content, provenance } = await this.templates.render(input.source, input.vars, {
|
||||||
|
channel: input.channel,
|
||||||
|
locale: input.locale,
|
||||||
|
scopeId: input.scopeId,
|
||||||
|
});
|
||||||
|
return this.outbound.send(
|
||||||
|
input.channel,
|
||||||
|
input.target,
|
||||||
|
this.toPayload(input.channel, content),
|
||||||
|
input.idempotencyKey,
|
||||||
|
input.scopeId,
|
||||||
|
input.purpose,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */
|
||||||
|
private toPayload(channel: ExternalChannel, content: RenderedContent): Record<string, unknown> {
|
||||||
|
if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text };
|
||||||
|
return { text: content.text ?? content.subject ?? '' }; // SMS: text only
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user