feat(templates): T2 IiosMessageTemplate table + resolve()

Schema + migration for the template source table, and TemplateRepository.resolve:
highest active version for (key,channel,locale), scoped override preferred over
the global (scopeId NULL) default.

Migration adds a PARTIAL unique index WHERE scopeId IS NULL so two platform
defaults for the same key can't coexist (Postgres treats NULL as distinct under
a plain UNIQUE — the scoped constraint alone wouldn't catch it). 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 12:23:44 +05:30
parent 8768af96c1
commit b3afd44d00
4 changed files with 180 additions and 0 deletions
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateNotFoundError, TemplateRepository } 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 repo = new TemplateRepository(prisma as unknown as PrismaService);
async function scope(): Promise<string> {
const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
return s.id;
}
async function seed(data: Partial<Parameters<typeof prisma.iiosMessageTemplate.create>[0]['data']> & { key: string }) {
return prisma.iiosMessageTemplate.create({
data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never,
});
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateRepository.resolve', () => {
it('returns the global default when no scope override exists', async () => {
await seed({ key: 'welcome', bodyText: 'global' });
const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope());
expect(t.bodyText).toBe('global');
expect(t.scopeId).toBeNull();
});
it('prefers the scoped override over the global default', async () => {
const scopeId = await scope();
await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL
await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override
const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId);
expect(t.bodyText).toBe('scoped');
expect(t.scopeId).toBe(scopeId);
});
it('returns the highest active version', async () => {
await seed({ key: 'welcome', version: 1, bodyText: 'v1' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3' });
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(3);
});
it('ignores inactive versions (a retracted v3 falls back to v2)', async () => {
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(2);
});
it('throws TemplateNotFoundError for an unknown key', async () => {
await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
it('does not cross channels (an EMAIL template is not an SMS template)', async () => {
await seed({ key: 'welcome', channel: 'EMAIL' });
await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
});
@@ -0,0 +1,42 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class TemplateNotFoundError extends NotFoundException {
constructor(key: string, channel: string, locale: string) {
super(`no active template for key="${key}" channel=${channel} locale=${locale}`);
this.name = 'TemplateNotFoundError';
}
}
@Injectable()
export class TemplateRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Resolve the template to use: the highest active version for (key, channel, locale), preferring
* a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId
* NULL). Throws if neither exists.
*/
async resolve(
key: string,
channel: IiosTemplateChannel,
locale = 'en',
scopeId?: string,
): Promise<IiosMessageTemplate> {
if (scopeId) {
const scoped = await this.highest({ key, channel, locale, scopeId });
if (scoped) return scoped;
}
const global = await this.highest({ key, channel, locale, scopeId: null });
if (global) return global;
throw new TemplateNotFoundError(key, channel, locale);
}
private highest(where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }) {
return this.prisma.iiosMessageTemplate.findFirst({
where: { ...where, active: true },
orderBy: { version: 'desc' },
});
}
}