# Template Version History
## Overview
The Template Version History system provides comprehensive audit trails for email template changes with automatic version creation, rollback capability, and change tracking. Every template save creates a new version snapshot, preserving the complete history of modifications with metadata about who changed what and why.
**Key Features:**
- **Automatic Version Creation** — Every save creates a new version (no manual versioning)
- **Auto-Incrementing Version Numbers** — Sequential numbering (1, 2, 3...) per template
- **Complete Snapshots** — Stores subject line, HTML content, and text content
- **Change Notes** — Optional admin-provided descriptions of changes
- **User Attribution** — Tracks who created each version
- **Rollback Capability** — Restore any previous version (non-destructive)
- **Version Comparison** — Visual diff between any two versions
- **Audit Trail** — Full history for compliance and debugging
**Benefits:**
- **Accident Recovery** — Undo mistakes by rolling back to previous version
- **Change Tracking** — See what changed and when
- **Compliance** — Audit trail for regulatory requirements
- **Collaboration** — Multiple admins can see each other's changes
- **Experimentation** — Safely test changes knowing you can rollback
- **Documentation** — Change notes explain why changes were made
---
## Architecture
```mermaid
flowchart TB
subgraph "Version Creation Flow"
Save[Admin Saves Template]
FindMax[Find Max Version Number]
Increment[Increment to Next Version]
CreateVersion[Create EmailTemplateVersion]
UpdateTemplate[Update EmailTemplate]
Save --> FindMax
FindMax --> Increment
Increment --> CreateVersion
CreateVersion --> UpdateTemplate
end
subgraph "Database Models"
Template[(EmailTemplate)]
Versions[(EmailTemplateVersion)]
Template -->|1:N| Versions
end
subgraph "Version Data"
Snapshot[Content Snapshot
subject, HTML, text]
Meta[Metadata
version number, change notes]
Attribution[Attribution
created by user, timestamp]
Snapshot --> Versions
Meta --> Versions
Attribution --> Versions
end
subgraph "Version Operations"
List[List Version History]
Compare[Compare Two Versions]
Rollback[Rollback to Version]
View[View Version Details]
Versions --> List
Versions --> Compare
Versions --> Rollback
Versions --> View
end
subgraph "Rollback Flow"
SelectVersion[Select Old Version]
LoadContent[Load Old Content]
UpdateCurrent[Update Current Template]
CreateNewVersion[Create New Version
'Rolled back to vX']
SelectVersion --> LoadContent
LoadContent --> UpdateCurrent
UpdateCurrent --> CreateNewVersion
CreateNewVersion --> Versions
end
Save --> Template
CreateVersion --> Versions
Rollback --> Template
style Save fill:#4a90e2,color:#fff
style CreateVersion fill:#50c878,color:#fff
style Rollback fill:#ff6b6b,color:#fff
```
**Component Responsibilities:**
- **EmailTemplateVersion** — Version snapshot storage with metadata
- **Version Service** — Auto-increment logic, version creation
- **Rollback Service** — Restore old version as new version (non-destructive)
- **Comparison Service** — Diff generation between versions
- **Audit Log** — User attribution and change notes
---
## Database Model
### EmailTemplateVersion Schema
**Table:** `email_template_versions`
| Field | Type | Description |
|-------|------|-------------|
| `id` | String (CUID) | Primary key |
| `templateId` | String | Foreign key to EmailTemplate |
| `versionNumber` | Int | Auto-incremented version (1, 2, 3...) |
| `subjectLine` | String | Subject line snapshot |
| `htmlContent` | Text | HTML content snapshot |
| `textContent` | Text | Plain text content snapshot |
| `changeNotes` | String (optional) | Admin-provided change description |
| `createdByUserId` | String (optional) | User who created this version |
| `createdAt` | DateTime | Version creation timestamp |
**Relations:**
- `template` — EmailTemplate (N:1)
- `createdBy` — User (N:1)
**Constraints:**
- Unique index on `(templateId, versionNumber)` for version lookup
- Auto-increment logic in service layer (finds max + 1)
- No ON DELETE CASCADE (preserve versions even if template deleted)
**Prisma Schema:**
```prisma
model EmailTemplateVersion {
id String @id @default(cuid())
templateId String
versionNumber Int
subjectLine String
htmlContent String @db.Text
textContent String @db.Text
changeNotes String?
createdByUserId String?
createdAt DateTime @default(now())
template EmailTemplate @relation(fields: [templateId], references: [id])
createdBy User? @relation(fields: [createdByUserId], references: [id])
@@unique([templateId, versionNumber])
@@index([templateId])
@@index([createdAt])
@@map("email_template_versions")
}
```
---
## Version Creation
### Automatic Versioning on Save
**When Versions Are Created:**
- Admin saves template via EmailTemplateEditorPage
- API `PUT /api/email-templates/:id` endpoint called
- Version created BEFORE updating template (snapshot current state)
**Auto-Increment Logic:**
```typescript
// api/src/modules/email-templates/email-templates.service.ts
async function createVersion(
templateId: string,
options: {
changeNotes?: string;
createdByUserId?: string;
}
) {
// 1. Find max version number for this template
const maxVersion = await prisma.emailTemplateVersion.findFirst({
where: { templateId },
orderBy: { versionNumber: 'desc' },
select: { versionNumber: true },
});
const nextVersion = (maxVersion?.versionNumber || 0) + 1;
// 2. Load current template content
const template = await prisma.emailTemplate.findUnique({
where: { id: templateId },
});
if (!template) {
throw new Error('Template not found');
}
// 3. Create version snapshot
const version = await prisma.emailTemplateVersion.create({
data: {
templateId,
versionNumber: nextVersion,
subjectLine: template.subjectLine,
htmlContent: template.htmlContent,
textContent: template.textContent,
changeNotes: options.changeNotes,
createdByUserId: options.createdByUserId,
},
});
return version;
}
```
**Save Template with Versioning:**
```typescript
// api/src/modules/email-templates/email-templates.routes.ts
router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {
const { id } = req.params;
const { subjectLine, htmlContent, textContent, changeNotes } = req.body;
try {
// 1. Create version BEFORE updating (snapshot current state)
await createVersion(id, {
changeNotes,
createdByUserId: req.user!.id,
});
// 2. Update template with new content
const updatedTemplate = await prisma.emailTemplate.update({
where: { id },
data: {
subjectLine,
htmlContent,
textContent,
updatedByUserId: req.user!.id,
},
});
res.json(updatedTemplate);
} catch (error) {
logger.error('Failed to save template', { error, templateId: id });
res.status(500).json({ error: 'Failed to save template' });
}
});
```
**Important:** Version is created BEFORE updating template, so version snapshots the OLD content (not the new content). This preserves the exact state before the change.
---
### Version Number Sequence
**Sequence Rules:**
- Starts at 1 for first version
- Increments by 1 for each save
- Per-template sequence (not global)
- No gaps in sequence
**Example Timeline:**
| Action | Version | Subject | HTML | Change Notes |
|--------|---------|---------|------|--------------|
| Create template | 1 | "Welcome!" | `
Hello
` | (initial version) | | Edit subject | 2 | "Welcome to Our Platform!" | `Hello
` | "Made subject more descriptive" | | Add content | 3 | "Welcome to Our Platform!" | `Hello {{USER_NAME}}
` | "Added user name variable" | | Rollback to v1 | 4 | "Welcome!" | `Hello
` | "Rolled back to version 1" | **Note:** Rollback creates NEW version (v4 in example), doesn't delete v2 and v3. This preserves complete audit trail. --- ### Change Notes **Purpose:** Describe what changed and why (audit trail documentation) **When Prompted:** - EmailTemplateEditorPage shows "Change Notes" field on save - Optional but recommended - Stored in `changeNotes` field **Examples:** **Good Change Notes:** ``` - "Added phone number conditional block" - "Fixed typo in subject line" - "Updated shift location variable to include address" - "Removed deprecated campaign URL variable" - "Rolled back to version 5 due to rendering issue" ``` **Poor Change Notes:** ``` - "update" (not descriptive) - "changes" (too vague) - "" (empty, no context) ``` **Implementation:** ```typescript // EmailTemplateEditorPage.tsx const [saveModalVisible, setSaveModalVisible] = useState(false); const [changeNotes, setChangeNotes] = useState(''); const handleSave = async () => { await api.put(`/api/email-templates/${id}`, { subjectLine, htmlContent, textContent, changeNotes: changeNotes || undefined, // Optional }); message.success('Template saved successfully'); navigate('/app/email-templates'); }; // Modal UI
{part.value}
);
});
}
```
---
### Rolling Back to Previous Version
**Step 1: Select Version to Restore**
- Version history table → click version row
**Step 2: Click "Restore" Button**
- Opens confirmation modal
**Step 3: Confirm Rollback**
- Modal shows:
- Version being restored (e.g., "Version 5")
- Warning: "This will create a new version with this content"
- Change notes field (pre-filled: "Rolled back to version 5")
**Step 4: Confirm and Save**
- Click "Confirm Restore"
- Creates new version (e.g., v10) with content from v5
- Current template updated to v5 content
- Redirects to EmailTemplatesPage
**Rollback Process:**
```typescript
// api/src/modules/email-templates/email-templates.routes.ts
router.post('/:id/rollback/:versionNumber', requireRole(SUPER_ADMIN), async (req, res) => {
const { id } = req.params;
const versionNumber = parseInt(req.params.versionNumber);
try {
// 1. Load version to restore
const versionToRestore = await prisma.emailTemplateVersion.findUnique({
where: {
templateId_versionNumber: {
templateId: id,
versionNumber,
},
},
});
if (!versionToRestore) {
return res.status(404).json({ error: 'Version not found' });
}
// 2. Create version snapshot BEFORE rollback (current state)
await createVersion(id, {
changeNotes: `Rolled back to version ${versionNumber}`,
createdByUserId: req.user!.id,
});
// 3. Update template with old version content
const updatedTemplate = await prisma.emailTemplate.update({
where: { id },
data: {
subjectLine: versionToRestore.subjectLine,
htmlContent: versionToRestore.htmlContent,
textContent: versionToRestore.textContent,
updatedByUserId: req.user!.id,
},
});
res.json(updatedTemplate);
} catch (error) {
logger.error('Failed to rollback template', { error, templateId: id, versionNumber });
res.status(500).json({ error: 'Failed to rollback template' });
}
});
```
**Important:** Rollback is non-destructive. It doesn't delete newer versions; it creates a NEW version with old content. This preserves the complete audit trail.
---
## Code Examples
### Creating Version Manually
**When to Use:**
- Seed script initialization
- Programmatic template updates
- Testing version history
**Example:**
```typescript
// Create initial version for new template
const template = await prisma.emailTemplate.create({
data: {
key: 'user-welcome',
name: 'Welcome Email',
category: 'SYSTEM',
subjectLine: 'Welcome!',
htmlContent: 'Hello {{USER_NAME}}
', textContent: 'Hello {{USER_NAME}}', isActive: true, }, }); // Create version 1 await prisma.emailTemplateVersion.create({ data: { templateId: template.id, versionNumber: 1, subjectLine: template.subjectLine, htmlContent: template.htmlContent, textContent: template.textContent, changeNotes: 'Initial template creation', createdByUserId: adminUser.id, }, }); ``` --- ### Loading Version History **Fetch All Versions:** ```typescript const versions = await prisma.emailTemplateVersion.findMany({ where: { templateId }, orderBy: { versionNumber: 'desc' }, include: { createdBy: { select: { name: true, email: true }, }, }, }); console.log('Version history:', versions); ``` **Fetch Specific Version:** ```typescript const version = await prisma.emailTemplateVersion.findUnique({ where: { templateId_versionNumber: { templateId: 'cuid123', versionNumber: 5, }, }, }); ``` **Fetch Latest Version:** ```typescript const latestVersion = await prisma.emailTemplateVersion.findFirst({ where: { templateId }, orderBy: { versionNumber: 'desc' }, }); console.log('Latest version:', latestVersion.versionNumber); ``` --- ### Version Diff Generation **Line-by-Line Diff:** ```typescript import { diffLines, Change } from 'diff'; interface VersionDiff { subject: Change[]; html: Change[]; text: Change[]; } function compareVersions( oldVersion: EmailTemplateVersion, newVersion: EmailTemplateVersion ): VersionDiff { return { subject: diffLines(oldVersion.subjectLine, newVersion.subjectLine), html: diffLines(oldVersion.htmlContent, newVersion.htmlContent), text: diffLines(oldVersion.textContent, newVersion.textContent), }; } ``` **Usage:** ```typescript const version5 = await prisma.emailTemplateVersion.findUnique({ where: { templateId_versionNumber: { templateId, versionNumber: 5 } }, }); const version6 = await prisma.emailTemplateVersion.findUnique({ where: { templateId_versionNumber: { templateId, versionNumber: 6 } }, }); const diff = compareVersions(version5, version6); console.log('Subject changes:', diff.subject); console.log('HTML changes:', diff.html); console.log('Text changes:', diff.text); ``` **Render Diff in UI:** ```typescript // admin/src/components/VersionDiff.tsx import { diffLines } from 'diff'; interface VersionDiffProps { oldContent: string; newContent: string; title: string; } export function VersionDiff({ oldContent, newContent, title }: VersionDiffProps) { const diff = diffLines(oldContent, newContent); return (
{diff.map((part, index) => {
let style = {};
if (part.added) {
style = { color: 'green', backgroundColor: '#e6ffed' };
} else if (part.removed) {
style = { color: 'red', backgroundColor: '#ffebe9' };
}
return (
{part.value}
);
})}