1234 lines
31 KiB
Markdown
1234 lines
31 KiB
Markdown
# 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<br/>subject, HTML, text]
|
|
Meta[Metadata<br/>version number, change notes]
|
|
Attribution[Attribution<br/>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<br/>'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!" | `<p>Hello</p>` | (initial version) |
|
|
| Edit subject | 2 | "Welcome to Our Platform!" | `<p>Hello</p>` | "Made subject more descriptive" |
|
|
| Add content | 3 | "Welcome to Our Platform!" | `<p>Hello {{USER_NAME}}</p>` | "Added user name variable" |
|
|
| Rollback to v1 | 4 | "Welcome!" | `<p>Hello</p>` | "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
|
|
<Modal title="Save Template" visible={saveModalVisible} onOk={handleSave}>
|
|
<Form.Item label="Change Notes (optional)">
|
|
<TextArea
|
|
value={changeNotes}
|
|
onChange={(e) => setChangeNotes(e.target.value)}
|
|
placeholder="Describe what changed in this version"
|
|
rows={4}
|
|
/>
|
|
</Form.Item>
|
|
</Modal>
|
|
```
|
|
|
|
---
|
|
|
|
## Admin Workflow
|
|
|
|
### Viewing Version History
|
|
|
|
**Step 1: Open Template Detail**
|
|
- EmailTemplatesPage → click template row
|
|
- Opens template detail modal
|
|
|
|
**Step 2: Navigate to "Version History" Tab**
|
|
- Click "Version History" tab
|
|
- Shows table of all versions
|
|
|
|
**Version History Table Columns:**
|
|
- **Version** — Version number (e.g., "v3")
|
|
- **Created** — Timestamp (e.g., "2026-03-15 14:23")
|
|
- **Created By** — User name (e.g., "John Doe")
|
|
- **Change Notes** — Description of changes
|
|
- **Actions** — View, Compare, Restore buttons
|
|
|
|
**Sorting:**
|
|
- Default: Descending by version number (newest first)
|
|
- Can sort by created date or version number
|
|
|
|
---
|
|
|
|
### Viewing Version Details
|
|
|
|
**Step 1: Click "View" Button**
|
|
- Version history table → click version row → "View" button
|
|
|
|
**Step 2: Version Detail Modal**
|
|
- Shows version metadata:
|
|
- Version number
|
|
- Created by user
|
|
- Created timestamp
|
|
- Change notes
|
|
- Shows content snapshot:
|
|
- Subject line
|
|
- HTML content (scrollable textarea)
|
|
- Text content (scrollable textarea)
|
|
|
|
**Step 3: Preview Rendered Version**
|
|
- Click "Preview" button
|
|
- Renders HTML with sample data
|
|
- Shows how email looked at that version
|
|
|
|
---
|
|
|
|
### Comparing Versions
|
|
|
|
**Step 1: Select Two Versions**
|
|
- Version history table → checkbox on two version rows
|
|
- Click "Compare Selected" button
|
|
|
|
**Step 2: Comparison Modal**
|
|
- Side-by-side diff view:
|
|
- **Left:** Older version
|
|
- **Right:** Newer version
|
|
- Highlighting:
|
|
- **Green:** Added lines
|
|
- **Red:** Deleted lines
|
|
- **Yellow:** Modified lines
|
|
|
|
**Comparison Sections:**
|
|
- **Subject Line Diff** — Shows changes in subject
|
|
- **HTML Content Diff** — Line-by-line HTML diff
|
|
- **Text Content Diff** — Line-by-line text diff
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
import { diffLines } from 'diff';
|
|
|
|
function renderDiff(oldContent: string, newContent: string) {
|
|
const diff = diffLines(oldContent, newContent);
|
|
|
|
return diff.map((part, index) => {
|
|
let color = 'black';
|
|
let backgroundColor = 'transparent';
|
|
|
|
if (part.added) {
|
|
color = 'green';
|
|
backgroundColor = '#e6ffed';
|
|
} else if (part.removed) {
|
|
color = 'red';
|
|
backgroundColor = '#ffebe9';
|
|
}
|
|
|
|
return (
|
|
<pre
|
|
key={index}
|
|
style={{
|
|
color,
|
|
backgroundColor,
|
|
margin: 0,
|
|
padding: '2px 4px',
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{part.value}
|
|
</pre>
|
|
);
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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: '<p>Hello {{USER_NAME}}</p>',
|
|
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 (
|
|
<div>
|
|
<h4>{title}</h4>
|
|
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
|
|
{diff.map((part, index) => {
|
|
let style = {};
|
|
|
|
if (part.added) {
|
|
style = { color: 'green', backgroundColor: '#e6ffed' };
|
|
} else if (part.removed) {
|
|
style = { color: 'red', backgroundColor: '#ffebe9' };
|
|
}
|
|
|
|
return (
|
|
<span key={index} style={style}>
|
|
{part.value}
|
|
</span>
|
|
);
|
|
})}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Rollback API Implementation
|
|
|
|
**Full Rollback Route:**
|
|
|
|
```typescript
|
|
// api/src/modules/email-templates/email-templates.routes.ts
|
|
|
|
import { Router } from 'express';
|
|
import { requireRole } from '@/middleware/auth';
|
|
import { prisma } from '@/config/database';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
const router = Router();
|
|
|
|
router.post('/:id/rollback/:versionNumber', requireRole('SUPER_ADMIN'), async (req, res) => {
|
|
const { id } = req.params;
|
|
const versionNumber = parseInt(req.params.versionNumber, 10);
|
|
|
|
if (isNaN(versionNumber) || versionNumber < 1) {
|
|
return res.status(400).json({ error: 'Invalid version number' });
|
|
}
|
|
|
|
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. Load current template
|
|
const currentTemplate = await prisma.emailTemplate.findUnique({
|
|
where: { id },
|
|
});
|
|
|
|
if (!currentTemplate) {
|
|
return res.status(404).json({ error: 'Template not found' });
|
|
}
|
|
|
|
// 3. Check if already at this version (no-op)
|
|
if (
|
|
currentTemplate.subjectLine === versionToRestore.subjectLine &&
|
|
currentTemplate.htmlContent === versionToRestore.htmlContent &&
|
|
currentTemplate.textContent === versionToRestore.textContent
|
|
) {
|
|
return res.status(400).json({ error: 'Template already matches this version' });
|
|
}
|
|
|
|
// 4. Use transaction for atomicity
|
|
await prisma.$transaction(async (tx) => {
|
|
// 4a. Create version snapshot of CURRENT state
|
|
const maxVersion = await tx.emailTemplateVersion.findFirst({
|
|
where: { templateId: id },
|
|
orderBy: { versionNumber: 'desc' },
|
|
select: { versionNumber: true },
|
|
});
|
|
|
|
const nextVersion = (maxVersion?.versionNumber || 0) + 1;
|
|
|
|
await tx.emailTemplateVersion.create({
|
|
data: {
|
|
templateId: id,
|
|
versionNumber: nextVersion,
|
|
subjectLine: currentTemplate.subjectLine,
|
|
htmlContent: currentTemplate.htmlContent,
|
|
textContent: currentTemplate.textContent,
|
|
changeNotes: `Rolled back to version ${versionNumber}`,
|
|
createdByUserId: req.user!.id,
|
|
},
|
|
});
|
|
|
|
// 4b. Update template with OLD version content
|
|
await tx.emailTemplate.update({
|
|
where: { id },
|
|
data: {
|
|
subjectLine: versionToRestore.subjectLine,
|
|
htmlContent: versionToRestore.htmlContent,
|
|
textContent: versionToRestore.textContent,
|
|
updatedByUserId: req.user!.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
// 5. Load updated template
|
|
const updatedTemplate = await prisma.emailTemplate.findUnique({
|
|
where: { id },
|
|
});
|
|
|
|
logger.info('Template rolled back', {
|
|
templateId: id,
|
|
toVersion: versionNumber,
|
|
userId: req.user!.id,
|
|
});
|
|
|
|
res.json(updatedTemplate);
|
|
} catch (error: any) {
|
|
logger.error('Failed to rollback template', {
|
|
error: error.message,
|
|
templateId: id,
|
|
versionNumber,
|
|
});
|
|
res.status(500).json({ error: 'Failed to rollback template' });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Problem: Version numbers not auto-incrementing
|
|
|
|
**Symptoms:**
|
|
- Duplicate version number error
|
|
- `P2002: Unique constraint failed on templateId_versionNumber`
|
|
|
|
**Causes:**
|
|
1. Race condition (two saves at same time)
|
|
2. Max version query returns wrong result
|
|
3. Database constraint violated
|
|
|
|
**Solutions:**
|
|
|
|
**Check max version:**
|
|
```sql
|
|
SELECT MAX(version_number) FROM email_template_versions
|
|
WHERE template_id = 'cuid123';
|
|
```
|
|
|
|
**Use transaction for atomicity:**
|
|
```typescript
|
|
await prisma.$transaction(async (tx) => {
|
|
// 1. Find max version
|
|
const maxVersion = await tx.emailTemplateVersion.findFirst({
|
|
where: { templateId },
|
|
orderBy: { versionNumber: 'desc' },
|
|
});
|
|
|
|
const nextVersion = (maxVersion?.versionNumber || 0) + 1;
|
|
|
|
// 2. Create version (within same transaction)
|
|
await tx.emailTemplateVersion.create({
|
|
data: {
|
|
templateId,
|
|
versionNumber: nextVersion,
|
|
// ...
|
|
},
|
|
});
|
|
});
|
|
```
|
|
|
|
**Reset sequence if needed:**
|
|
```sql
|
|
-- Check for gaps
|
|
SELECT version_number FROM email_template_versions
|
|
WHERE template_id = 'cuid123'
|
|
ORDER BY version_number;
|
|
|
|
-- If gaps exist, renumber (DANGEROUS, only in dev)
|
|
UPDATE email_template_versions
|
|
SET version_number = (
|
|
SELECT COUNT(*) FROM email_template_versions AS v2
|
|
WHERE v2.template_id = email_template_versions.template_id
|
|
AND v2.created_at <= email_template_versions.created_at
|
|
)
|
|
WHERE template_id = 'cuid123';
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Rollback creates infinite versions
|
|
|
|
**Symptoms:**
|
|
- Rollback triggers another rollback
|
|
- Version numbers increment rapidly
|
|
|
|
**Causes:**
|
|
1. Rollback doesn't use transaction
|
|
2. Version creation triggers template update hook
|
|
|
|
**Solutions:**
|
|
|
|
**Use atomic transaction:**
|
|
```typescript
|
|
await prisma.$transaction(async (tx) => {
|
|
// Create version + update template in same transaction
|
|
await tx.emailTemplateVersion.create({ ... });
|
|
await tx.emailTemplate.update({ ... });
|
|
});
|
|
```
|
|
|
|
**Disable hooks during rollback:**
|
|
```typescript
|
|
// If using Prisma middleware, skip version creation during rollback
|
|
prisma.$use(async (params, next) => {
|
|
if (params.model === 'EmailTemplate' && params.action === 'update') {
|
|
// Check if this is a rollback operation (via context flag)
|
|
if (params.args.data._isRollback) {
|
|
delete params.args.data._isRollback;
|
|
return next(params); // Skip version creation
|
|
}
|
|
|
|
// Normal update: create version
|
|
await createVersion(params.args.where.id);
|
|
}
|
|
|
|
return next(params);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Version history shows duplicate content
|
|
|
|
**Symptoms:**
|
|
- Multiple versions with identical content
|
|
- Version numbers increment but content unchanged
|
|
|
|
**Causes:**
|
|
1. Save triggered multiple times (double-click)
|
|
2. No dirty check before saving
|
|
|
|
**Solutions:**
|
|
|
|
**Add content comparison before save:**
|
|
```typescript
|
|
router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {
|
|
const { id } = req.params;
|
|
const { subjectLine, htmlContent, textContent, changeNotes } = req.body;
|
|
|
|
// 1. Load current template
|
|
const currentTemplate = await prisma.emailTemplate.findUnique({ where: { id } });
|
|
|
|
// 2. Check if content changed
|
|
if (
|
|
currentTemplate.subjectLine === subjectLine &&
|
|
currentTemplate.htmlContent === htmlContent &&
|
|
currentTemplate.textContent === textContent
|
|
) {
|
|
return res.status(400).json({ error: 'No changes detected' });
|
|
}
|
|
|
|
// 3. Create version + update template
|
|
await createVersion(id, { changeNotes, createdByUserId: req.user!.id });
|
|
await prisma.emailTemplate.update({ where: { id }, data: { subjectLine, htmlContent, textContent } });
|
|
|
|
res.json({ success: true });
|
|
});
|
|
```
|
|
|
|
**Debounce save button:**
|
|
```typescript
|
|
// EmailTemplateEditorPage.tsx
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
if (saving) return; // Prevent double-click
|
|
|
|
setSaving(true);
|
|
try {
|
|
await api.put(`/api/email-templates/${id}`, { ... });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Version comparison shows no diff
|
|
|
|
**Symptoms:**
|
|
- Comparison modal shows identical content
|
|
- No green/red highlighting
|
|
|
|
**Causes:**
|
|
1. Comparing version with itself
|
|
2. Versions truly identical (duplicate save)
|
|
|
|
**Solutions:**
|
|
|
|
**Prevent self-comparison:**
|
|
```typescript
|
|
function handleCompare(version1: number, version2: number) {
|
|
if (version1 === version2) {
|
|
message.error('Cannot compare version with itself');
|
|
return;
|
|
}
|
|
|
|
// Load and compare versions...
|
|
}
|
|
```
|
|
|
|
**Check versions exist:**
|
|
```sql
|
|
SELECT version_number, LENGTH(html_content) AS html_length
|
|
FROM email_template_versions
|
|
WHERE template_id = 'cuid123'
|
|
AND version_number IN (5, 6);
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Rollback doesn't restore variables
|
|
|
|
**Symptoms:**
|
|
- Template content rolled back
|
|
- Variables not restored (still showing new variables)
|
|
|
|
**Causes:**
|
|
- Variables stored separately (not in version snapshot)
|
|
|
|
**Current Limitation:**
|
|
- EmailTemplateVersion only stores content (subject, HTML, text)
|
|
- Does NOT store variable definitions
|
|
- Rolling back template doesn't affect variables
|
|
|
|
**Workaround:**
|
|
- Manually restore variables via admin UI
|
|
- Future enhancement: Version variable definitions too
|
|
|
|
**Future Enhancement:**
|
|
```typescript
|
|
// Add to EmailTemplateVersion model
|
|
variablesSnapshot: Prisma.JsonValue // JSON array of variables
|
|
|
|
// When creating version, snapshot variables
|
|
const variables = await prisma.emailTemplateVariable.findMany({
|
|
where: { templateId },
|
|
});
|
|
|
|
await prisma.emailTemplateVersion.create({
|
|
data: {
|
|
// ...
|
|
variablesSnapshot: variables as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
// When rolling back, restore variables
|
|
const variablesSnapshot = versionToRestore.variablesSnapshot as EmailTemplateVariable[];
|
|
|
|
for (const variable of variablesSnapshot) {
|
|
await prisma.emailTemplateVariable.upsert({
|
|
where: { templateId_key: { templateId, key: variable.key } },
|
|
update: variable,
|
|
create: { templateId, ...variable },
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
### Version Storage Growth
|
|
|
|
**Storage Impact:**
|
|
- Each version stores 3 text fields (subject, HTML, text)
|
|
- Typical template: 5-20KB per version
|
|
- 100 versions = 500KB - 2MB per template
|
|
|
|
**Optimization Options:**
|
|
|
|
**1. Compress Old Versions:**
|
|
```typescript
|
|
import zlib from 'zlib';
|
|
|
|
// Compress HTML content before storing
|
|
const compressedHtml = zlib.gzipSync(htmlContent).toString('base64');
|
|
|
|
await prisma.emailTemplateVersion.create({
|
|
data: {
|
|
// ...
|
|
htmlContent: compressedHtml,
|
|
isCompressed: true, // Add flag
|
|
},
|
|
});
|
|
|
|
// Decompress when loading
|
|
if (version.isCompressed) {
|
|
const buffer = Buffer.from(version.htmlContent, 'base64');
|
|
const htmlContent = zlib.gunzipSync(buffer).toString('utf-8');
|
|
}
|
|
```
|
|
|
|
**2. Archive Old Versions:**
|
|
```sql
|
|
-- Move versions > 1 year old to archive table
|
|
INSERT INTO email_template_versions_archive
|
|
SELECT * FROM email_template_versions
|
|
WHERE created_at < NOW() - INTERVAL '1 year';
|
|
|
|
DELETE FROM email_template_versions
|
|
WHERE created_at < NOW() - INTERVAL '1 year';
|
|
```
|
|
|
|
**3. Limit Version History:**
|
|
```typescript
|
|
// Keep only last 50 versions per template
|
|
const oldVersions = await prisma.emailTemplateVersion.findMany({
|
|
where: { templateId },
|
|
orderBy: { versionNumber: 'desc' },
|
|
skip: 50, // Skip first 50 (keep these)
|
|
});
|
|
|
|
// Delete versions beyond 50
|
|
await prisma.emailTemplateVersion.deleteMany({
|
|
where: {
|
|
id: { in: oldVersions.map(v => v.id) },
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### Version Diff Performance
|
|
|
|
**Performance Impact:**
|
|
- Diff generation is CPU-intensive for large templates
|
|
- `diffLines` algorithm is O(n*m) where n, m = line counts
|
|
|
|
**Optimization:**
|
|
|
|
**1. Cache Diff Results:**
|
|
```typescript
|
|
const diffCache = new Map<string, Change[]>();
|
|
|
|
function getCachedDiff(oldContent: string, newContent: string): Change[] {
|
|
const cacheKey = `${hashString(oldContent)}-${hashString(newContent)}`;
|
|
|
|
if (diffCache.has(cacheKey)) {
|
|
return diffCache.get(cacheKey)!;
|
|
}
|
|
|
|
const diff = diffLines(oldContent, newContent);
|
|
diffCache.set(cacheKey, diff);
|
|
|
|
return diff;
|
|
}
|
|
```
|
|
|
|
**2. Limit Diff Size:**
|
|
```typescript
|
|
// For very large templates, show summary instead of full diff
|
|
if (oldContent.length > 100000 || newContent.length > 100000) {
|
|
return {
|
|
error: 'Template too large for diff. Use version preview instead.',
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### Change Notes Guidelines
|
|
|
|
**Always Provide Change Notes:**
|
|
- Documents WHY changes were made (not just WHAT)
|
|
- Helps future admins understand context
|
|
- Useful for compliance audits
|
|
|
|
**Be Specific:**
|
|
```
|
|
✓ Good:
|
|
- "Added USER_PHONE variable with conditional block"
|
|
- "Fixed typo in subject line (Welcome vs Welcom)"
|
|
- "Updated shift location to include full address"
|
|
|
|
✗ Bad:
|
|
- "updates"
|
|
- "changes"
|
|
- "fix"
|
|
```
|
|
|
|
**Reference Issues/Tickets:**
|
|
```
|
|
"Fixed rendering issue in Gmail (Ticket #123)"
|
|
"Added new variable per Sarah's request"
|
|
```
|
|
|
|
---
|
|
|
|
### Rollback Safety
|
|
|
|
**Always Review Before Rollback:**
|
|
- View version content before restoring
|
|
- Compare with current version
|
|
- Understand what will change
|
|
|
|
**Use Change Notes:**
|
|
```
|
|
"Rolled back to version 5 - version 6 broke email rendering in Outlook"
|
|
```
|
|
|
|
**Test After Rollback:**
|
|
- Send test email after rollback
|
|
- Verify rendering correct
|
|
- Check all variables still work
|
|
|
|
---
|
|
|
|
### Version Retention
|
|
|
|
**Keep All Versions (Default):**
|
|
- Complete audit trail
|
|
- Compliance requirements
|
|
|
|
**Archive Old Versions (Optional):**
|
|
- Templates with 100+ versions
|
|
- Versions older than 1 year
|
|
- Move to separate archive table
|
|
|
|
**Never Delete Versions:**
|
|
- Breaks audit trail
|
|
- May violate compliance requirements
|
|
- Disk space is cheap
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
### Frontend Documentation
|
|
|
|
- **[EmailTemplatesPage.tsx](../../frontend/pages/email-templates-page.md)** — Version history tab
|
|
- **[EmailTemplateEditorPage.tsx](../../frontend/pages/email-template-editor-page.md)** — Change notes field
|
|
|
|
### Backend Documentation
|
|
|
|
- **[Email Templates Module](../../api/modules/email-templates.md)** — Version API routes
|
|
- `GET /api/email-templates/:id/versions` — List versions
|
|
- `GET /api/email-templates/:id/versions/:versionNumber` — Get version details
|
|
- `POST /api/email-templates/:id/rollback/:versionNumber` — Rollback to version
|
|
|
|
### Database Documentation
|
|
|
|
- **[Email Templates Models](../../database/models/email-templates.md)** — EmailTemplateVersion schema
|
|
|
|
### Feature Documentation
|
|
|
|
- **[template-system.md](./template-system.md)** — Email template engine overview
|
|
- **[editor.md](./editor.md)** — Email template editor interface
|
|
- **[variables.md](./variables.md)** — Template variable system
|