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
|
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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user