Feat/s3 storage #1
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,19 +23,26 @@ export class TemplateRepository {
|
|||||||
channel: IiosTemplateChannel,
|
channel: IiosTemplateChannel,
|
||||||
locale = 'en',
|
locale = 'en',
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
|
version?: number,
|
||||||
): Promise<IiosMessageTemplate> {
|
): Promise<IiosMessageTemplate> {
|
||||||
|
// Scoped override first, then the global default — for both the pinned and highest-active paths.
|
||||||
if (scopeId) {
|
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;
|
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;
|
if (global) return global;
|
||||||
throw new TemplateNotFoundError(key, channel, locale);
|
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({
|
return this.prisma.iiosMessageTemplate.findFirst({
|
||||||
where: { ...where, active: true },
|
where: version != null ? { ...where, version } : { ...where, active: true },
|
||||||
orderBy: { version: 'desc' },
|
orderBy: { version: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '<p>{{firstName}}</p>', variables: ['firstName'] },
|
||||||
|
});
|
||||||
|
const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' });
|
||||||
|
expect(content.subject).toBe('Hi Dana');
|
||||||
|
expect(content.html).toBe('<p>Dana</p>');
|
||||||
|
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: '<b>{{x}}</b>', variables: ['x'] } },
|
||||||
|
{ x: 'Y' },
|
||||||
|
{ channel: 'EMAIL' },
|
||||||
|
);
|
||||||
|
expect(content.subject).toBe('Ad-hoc Y');
|
||||||
|
expect(content.html).toBe('<b>Y</b>');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>, opts: RenderOptions): Promise<RenderResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user