31 KiB

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

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:

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:

// 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:

// 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:

// 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:

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:

// 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:

// 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:

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:

const version = await prisma.emailTemplateVersion.findUnique({
  where: {
    templateId_versionNumber: {
      templateId: 'cuid123',
      versionNumber: 5,
    },
  },
});

Fetch Latest Version:

const latestVersion = await prisma.emailTemplateVersion.findFirst({
  where: { templateId },
  orderBy: { versionNumber: 'desc' },
});

console.log('Latest version:', latestVersion.versionNumber);

Version Diff Generation

Line-by-Line Diff:

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:

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:

// 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:

// 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:

SELECT MAX(version_number) FROM email_template_versions
WHERE template_id = 'cuid123';

Use transaction for atomicity:

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:

-- 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:

await prisma.$transaction(async (tx) => {
  // Create version + update template in same transaction
  await tx.emailTemplateVersion.create({ ... });
  await tx.emailTemplate.update({ ... });
});

Disable hooks during rollback:

// 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:

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:

// 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:

function handleCompare(version1: number, version2: number) {
  if (version1 === version2) {
    message.error('Cannot compare version with itself');
    return;
  }

  // Load and compare versions...
}

Check versions exist:

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:

// 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:

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:

-- 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:

// 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:

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:

// 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

Frontend Documentation

Backend Documentation

  • Email Templates Module — 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

Feature Documentation