Feat/s3 storage #1
+39
@@ -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
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user