From 8ce647552ad9604168ddf0cd3fecd67746281424 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:25:27 +0530 Subject: [PATCH] feat(templates): T3 render() service (resolve+render, inline, version pin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateService.render(source, vars, opts) → { content, provenance }. Stored key → resolve+render; inline → render without a DB row (key/version null); explicit version pins. Provenance = {templateKey, version, locale, sha256(content)}. 10 tests (svc + repo). Co-Authored-By: Claude Opus 4.8 --- .../src/templates/template.model.ts | 16 ++++++ .../src/templates/template.repository.ts | 15 +++-- .../src/templates/template.service.spec.ts | 55 +++++++++++++++++++ .../src/templates/template.service.ts | 55 +++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 packages/iios-service/src/templates/template.model.ts create mode 100644 packages/iios-service/src/templates/template.service.spec.ts create mode 100644 packages/iios-service/src/templates/template.service.ts diff --git a/packages/iios-service/src/templates/template.model.ts b/packages/iios-service/src/templates/template.model.ts new file mode 100644 index 0000000..20bf6a6 --- /dev/null +++ b/packages/iios-service/src/templates/template.model.ts @@ -0,0 +1,16 @@ +import type { IiosTemplateChannel } from '@prisma/client'; + +/** What to render: a STORED template (by key, optionally pinned to a version) or INLINE content. */ +export type TemplateSource = + | { key: string; version?: number } + | { inline: { subject?: string; html?: string; text?: string; variables?: string[] } }; + +export interface RenderOptions { + channel: IiosTemplateChannel; + locale?: string; + scopeId?: string; +} + +export function isInlineSource(s: TemplateSource): s is { inline: { subject?: string; html?: string; text?: string; variables?: string[] } } { + return 'inline' in s; +} diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts index b34d988..248ad0a 100644 --- a/packages/iios-service/src/templates/template.repository.ts +++ b/packages/iios-service/src/templates/template.repository.ts @@ -23,19 +23,26 @@ export class TemplateRepository { channel: IiosTemplateChannel, locale = 'en', scopeId?: string, + version?: number, ): Promise { + // Scoped override first, then the global default — for both the pinned and highest-active paths. if (scopeId) { - const scoped = await this.highest({ key, channel, locale, scopeId }); + const scoped = await this.pick({ key, channel, locale, scopeId }, version); if (scoped) return scoped; } - const global = await this.highest({ key, channel, locale, scopeId: null }); + const global = await this.pick({ key, channel, locale, scopeId: null }, version); if (global) return global; throw new TemplateNotFoundError(key, channel, locale); } - private highest(where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }) { + /** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest + * active version — a retracted (inactive) newest version falls back to the last good one. */ + private pick( + where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }, + version?: number, + ) { return this.prisma.iiosMessageTemplate.findFirst({ - where: { ...where, active: true }, + where: version != null ? { ...where, version } : { ...where, active: true }, orderBy: { version: 'desc' }, }); } diff --git a/packages/iios-service/src/templates/template.service.spec.ts b/packages/iios-service/src/templates/template.service.spec.ts new file mode 100644 index 0000000..e3b171d --- /dev/null +++ b/packages/iios-service/src/templates/template.service.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplateNotFoundError } from './template.repository'; +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 svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService)); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateService.render', () => { + it('resolves a stored template and renders it, with provenance', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '

{{firstName}}

', variables: ['firstName'] }, + }); + const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' }); + expect(content.subject).toBe('Hi Dana'); + expect(content.html).toBe('

Dana

'); + expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' }); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('renders an inline source without a DB row (key/version null)', async () => { + const { content, provenance } = await svc.render( + { inline: { subject: 'Ad-hoc {{x}}', html: '{{x}}', variables: ['x'] } }, + { x: 'Y' }, + { channel: 'EMAIL' }, + ); + expect(content.subject).toBe('Ad-hoc Y'); + expect(content.html).toBe('Y'); + expect(provenance.templateKey).toBeNull(); + expect(provenance.templateVersion).toBeNull(); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('pins to an explicit version when asked', async () => { + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } }); + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } }); + const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' }); + const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' }); + expect(latest.content.text).toBe('v2'); + expect(pinned.content.text).toBe('v1'); + expect(pinned.provenance.templateVersion).toBe(1); + }); + + it('throws for an unknown stored key', async () => { + await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.service.ts b/packages/iios-service/src/templates/template.service.ts new file mode 100644 index 0000000..4ed9bc5 --- /dev/null +++ b/packages/iios-service/src/templates/template.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { TemplateRepository } from './template.repository'; +import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer'; +import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model'; + +/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */ +export interface RenderProvenance { + templateKey: string | null; // null for inline sources + templateVersion: number | null; + templateLocale: string | null; + renderedHash: string; // sha256 of the rendered content +} + +export interface RenderResult { + content: RenderedContent; + provenance: RenderProvenance; +} + +@Injectable() +export class TemplateService { + constructor(private readonly repo: TemplateRepository) {} + + /** + * Resolve (if a stored key) and render a template with `vars`, returning the content plus the + * provenance to record on the send. Inline sources skip the DB and carry null key/version — the + * caller owns inline content, so no declared-variable contract is enforced for it. + */ + async render(source: TemplateSource, vars: Record, opts: RenderOptions): Promise { + const locale = opts.locale ?? 'en'; + + if (isInlineSource(source)) { + const raw: RawTemplate = { + subject: source.inline.subject, + bodyHtml: source.inline.html, + bodyText: source.inline.text, + variables: source.inline.variables ?? [], + }; + const content = renderTemplate(raw, vars); + return { content, provenance: this.provenance(content, null, null, null) }; + } + + const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version); + const content = renderTemplate( + { subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] }, + vars, + ); + return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) }; + } + + private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance { + const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex'); + return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash }; + } +}