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
@@ -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())