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,39 @@
-- Reusable message templates (email/SMS/in-app). DB is runtime truth; platform defaults seeded
-- from repo files on boot. Versions are immutable (a change = a new higher version).
CREATE TYPE "IiosTemplateChannel" AS ENUM ('EMAIL', 'SMS', 'INTERNAL');
CREATE TABLE "IiosMessageTemplate" (
"id" TEXT NOT NULL,
"scopeId" TEXT,
"key" TEXT NOT NULL,
"channel" "IiosTemplateChannel" NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'en',
"version" INTEGER NOT NULL DEFAULT 1,
"subject" TEXT,
"bodyHtml" TEXT,
"bodyText" TEXT,
"variables" JSONB,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosMessageTemplate_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "IiosMessageTemplate"
ADD CONSTRAINT "IiosMessageTemplate_scopeId_fkey"
FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Uniqueness for SCOPED rows (scopeId NOT NULL).
CREATE UNIQUE INDEX "IiosMessageTemplate_scopeId_key_channel_locale_version_key"
ON "IiosMessageTemplate" ("scopeId", "key", "channel", "locale", "version");
-- Uniqueness for GLOBAL rows (scopeId IS NULL). Postgres treats NULL as distinct under a normal
-- UNIQUE, so the constraint above does NOT stop two platform defaults for the same key — this
-- partial index does. (Same footgun handled in the inbox idempotency migration.)
CREATE UNIQUE INDEX "IiosMessageTemplate_global_key"
ON "IiosMessageTemplate" ("key", "channel", "locale", "version")
WHERE "scopeId" IS NULL;
-- Resolution lookup: by key/channel/locale among active rows.
CREATE INDEX "IiosMessageTemplate_key_channel_locale_active_idx"
ON "IiosMessageTemplate" ("key", "channel", "locale", "active");
@@ -96,6 +96,12 @@ enum IiosInboxState {
STALE STALE
} }
enum IiosTemplateChannel {
EMAIL
SMS
INTERNAL
}
enum IiosTicketState { enum IiosTicketState {
NEW NEW
OPEN OPEN
@@ -310,6 +316,7 @@ model IiosScope {
tickets IiosTicket[] tickets IiosTicket[]
callbacks IiosCallbackRequest[] callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[] notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
@@ -677,6 +684,33 @@ model IiosInboxItem {
@@index([ownerActorId, state, priority]) @@index([ownerActorId, state, priority])
} }
/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are
/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version,
/// never edits in place, so a rendered send can always be traced to the exact source it used.
/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution
/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is
/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.)
model IiosMessageTemplate {
id String @id @default(cuid())
scopeId String?
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
key String
channel IiosTemplateChannel
locale String @default("en")
version Int @default(1)
subject String?
bodyHtml String?
bodyText String?
/// Declared variable names — render throws if a declared var is missing (fail loud).
variables Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, key, channel, locale, version])
@@index([key, channel, locale, active])
}
/// Audit trail of inbox-item state transitions. /// Audit trail of inbox-item state transitions.
model IiosInboxItemStateHistory { model IiosInboxItemStateHistory {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -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' },
});
}
}