From 10f3545a2557cad627a42ea242d75c0acb7f158d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:28:28 +0530 Subject: [PATCH] feat(templates): T4 sendTemplated() + outbound provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IiosOutboundCommand gains templateKey/version/locale/renderedHash (migration). - OutboundService.send accepts an optional opaque `provenance` and writes it into the command on BOTH the PENDING and RATE_LIMITED create paths — the only way to stamp provenance by construction, since send() creates the row itself (review finding). OutboundService still neither renders nor resolves templates. - TemplatedSender.sendTemplated: render -> OutboundService.send -> provenance, one entry point. EMAIL payload = {subject,html,text}; SMS = {text}. Idempotent per key. Tests: 4 sender + 16 adapters regression green. Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 9 +++ packages/iios-service/prisma/schema.prisma | 5 ++ .../src/adapters/outbound.service.ts | 14 +++- .../src/capability/capability.registry.ts | 2 +- .../src/templates/templated-sender.spec.ts | 80 +++++++++++++++++++ .../src/templates/templated-sender.ts | 55 +++++++++++++ 6 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql create mode 100644 packages/iios-service/src/templates/templated-sender.spec.ts create mode 100644 packages/iios-service/src/templates/templated-sender.ts diff --git a/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql b/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql new file mode 100644 index 0000000..99beb7f --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql @@ -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; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 59b2b1a..bda1038 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -868,6 +868,11 @@ model IiosOutboundCommand { idempotencyKey String @unique providerRef String? 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()) attempts IiosDeliveryAttempt[] diff --git a/packages/iios-service/src/adapters/outbound.service.ts b/packages/iios-service/src/adapters/outbound.service.ts index c0370c8..a6ea69a 100644 --- a/packages/iios-service/src/adapters/outbound.service.ts +++ b/packages/iios-service/src/adapters/outbound.service.ts @@ -34,8 +34,18 @@ export class OutboundService { idempotencyKey?: string, scopeId?: 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 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 // 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). if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) { 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' } }); return cmd; } 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; diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index 4e4161d..c055fc0 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -4,7 +4,7 @@ import { SandboxProvider } from './sandbox.provider'; import { HttpProvider } from './http.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 diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts new file mode 100644 index 0000000..4c05745 --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -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: '

{{amount}}

', 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: '{{n}}', 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' }); + }); +}); diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts new file mode 100644 index 0000000..4b4373f --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -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; + scopeId?: string; + locale?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + 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 { + if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text }; + return { text: content.text ?? content.subject ?? '' }; // SMS: text only + } +}