diff --git a/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql b/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql new file mode 100644 index 0000000..6052957 --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql @@ -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"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 9b4aa91..59b2b1a 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -96,6 +96,12 @@ enum IiosInboxState { STALE } +enum IiosTemplateChannel { + EMAIL + SMS + INTERNAL +} + enum IiosTicketState { NEW OPEN @@ -310,6 +316,7 @@ model IiosScope { tickets IiosTicket[] callbacks IiosCallbackRequest[] notificationSubscriptions IiosNotificationSubscription[] + messageTemplates IiosMessageTemplate[] @@index([orgId, appId, tenantId]) } @@ -677,6 +684,33 @@ model IiosInboxItem { @@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. model IiosInboxItemStateHistory { id String @id @default(cuid()) diff --git a/packages/iios-service/src/templates/template.repository.spec.ts b/packages/iios-service/src/templates/template.repository.spec.ts new file mode 100644 index 0000000..1e10040 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.spec.ts @@ -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 { + const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + return s.id; +} +async function seed(data: Partial[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); + }); +}); diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts new file mode 100644 index 0000000..b34d988 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.ts @@ -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 { + 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' }, + }); + } +}