33 KiB

Template Variables System

Overview

The Template Variables System defines reusable placeholders for email templates, enabling dynamic content interpolation with validation, documentation, and sample values. Variables are defined per template and provide metadata for variable insertion UI, validation logic, and testing workflows.

Key Features:

  • Per-Template Variables — Each template has its own variable definitions
  • Required vs Optional — Enforce required variables at runtime
  • Conditional Variables — Boolean/truthy flags for {{#if}} blocks
  • Sample Values — Example data for testing and preview
  • Sort Order — Control display order in editor UI
  • Documentation — Labels and descriptions for self-documenting templates
  • Validation — Runtime checks prevent missing variable errors
  • Reusability — Common variables (USER_NAME, USER_EMAIL) across templates

Benefits:

  • Type Safety — Know what data is expected before sending
  • Self-Documentation — Variables describe their purpose
  • Better Testing — Sample values pre-fill test send forms
  • Consistency — Standardized variable naming across templates
  • Error Prevention — Catch missing variables before SMTP send

Architecture

flowchart TB
    subgraph "Database Layer"
        Template[(EmailTemplate)]
        Variables[(EmailTemplateVariable)]

        Template -->|1:N| Variables
    end

    subgraph "Variable Definition"
        VarKey[Variable Key<br/>USER_NAME]
        VarMeta[Metadata<br/>label, description, isRequired]
        VarSample[Sample Value<br/>'John Doe']
        VarSort[Sort Order<br/>1, 2, 3...]

        VarKey --> Variables
        VarMeta --> Variables
        VarSample --> Variables
        VarSort --> Variables
    end

    subgraph "Template Service"
        Load[Load Template + Variables]
        Validate[Validate Required Variables]
        Interpolate[Handlebars Interpolation]

        Load --> Template
        Load --> Variables
        Validate --> Variables
        Interpolate -->|{{VAR}}| Data[Data Object]
    end

    subgraph "Editor UI"
        InsertPanel[Variable Insertion Panel]
        PreviewForm[Sample Data Form]
        TestSend[Test Send Form]

        Variables --> InsertPanel
        Variables --> PreviewForm
        Variables --> TestSend
    end

    subgraph "Runtime Validation"
        Send[Send Email]
        Check{Required<br/>Variables<br/>Present?}
        Error[Throw MissingVariableError]
        Success[Send via SMTP]

        Send --> Validate
        Validate --> Check
        Check -->|No| Error
        Check -->|Yes| Interpolate
        Interpolate --> Success
    end

    style Template fill:#50c878,color:#fff
    style Variables fill:#4a90e2,color:#fff
    style Validate fill:#ffb347,color:#333

Component Responsibilities:

  • EmailTemplateVariable — Database model storing variable metadata
  • Variable Insertion Panel — Editor UI for inserting {{VARIABLES}}
  • Sample Data Form — Preview/test form pre-filled with sample values
  • Validation Service — Runtime checks before template interpolation
  • Handlebars Engine — Replaces {{VAR}} with data values

Database Model

EmailTemplateVariable Schema

Table: email_template_variables

Field Type Description
id String (CUID) Primary key
templateId String Foreign key to EmailTemplate
key String Variable name (UPPERCASE_WITH_UNDERSCORES)
label String Display label for UI ("User's Full Name")
description String (optional) Variable purpose and usage notes
isRequired Boolean If true, must be provided in data object
isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy)
sampleValue String (optional) Example value for testing/preview
sortOrder Int Display order in UI (1, 2, 3...)
createdAt DateTime Creation timestamp

Relations:

  • template — EmailTemplate (N:1)

Constraints:

  • Unique index on (templateId, key) — prevents duplicate variables per template
  • Index on sortOrder for ordered queries

Prisma Schema:

model EmailTemplateVariable {
  id            String   @id @default(cuid())
  templateId    String
  key           String
  label         String
  description   String?
  isRequired    Boolean  @default(false)
  isConditional Boolean  @default(false)
  sampleValue   String?
  sortOrder     Int      @default(0)
  createdAt     DateTime @default(now())

  template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)

  @@unique([templateId, key])
  @@index([sortOrder])
  @@map("email_template_variables")
}

Variable Types

Required Variables

Purpose: Must be provided in data object for template to send.

Behavior:

  • Validation checks for presence before interpolation
  • Throws MissingRequiredVariableError if missing
  • Marked with red "Required" badge in editor UI

When to Use:

  • Variables that appear in ALL template renders
  • Variables without fallback values
  • Critical data (e.g., recipient name, event date)

Example:

await prisma.emailTemplateVariable.create({
  data: {
    templateId: template.id,
    key: 'USER_NAME',
    label: 'User Name',
    description: 'Full name of the email recipient',
    isRequired: true,  // ← MUST be provided
    isConditional: false,
    sampleValue: 'John Doe',
    sortOrder: 1,
  },
});

Template Usage:

<p>Dear {{USER_NAME}},</p>
<!-- USER_NAME is required, error if missing -->

Optional Variables

Purpose: May be omitted from data object (defaults to empty string).

Behavior:

  • No validation error if missing
  • Handlebars renders as empty string if undefined
  • Useful for conditional content or nice-to-have data

When to Use:

  • Variables that may not always be available (e.g., phone number)
  • Variables with fallback text in template
  • Conditional blocks that check presence

Example:

await prisma.emailTemplateVariable.create({
  data: {
    templateId: template.id,
    key: 'USER_PHONE',
    label: 'User Phone',
    description: 'User phone number (optional)',
    isRequired: false,  // ← Can be omitted
    isConditional: false,
    sampleValue: '(555) 123-4567',
    sortOrder: 5,
  },
});

Template Usage:

{{#if USER_PHONE}}
<p>We'll call you at {{USER_PHONE}}.</p>
{{else}}
<p>Add a phone number to receive SMS updates.</p>
{{/if}}

Conditional Variables

Purpose: Boolean or truthy/falsy values for {{#if}} blocks.

Behavior:

  • isConditional: true marks variable as boolean-like
  • Editor UI shows blue "Conditional" badge
  • Used in {{#if VAR}}...{{/if}} blocks
  • Can also be required or optional

When to Use:

  • Boolean flags (HAS_PHONE, IS_VERIFIED, IS_ADMIN)
  • Existence checks (HAS_CUSTOM_MESSAGE, HAS_LOCATION)
  • Feature flags (SHOW_DISCOUNT, SHOW_MAP_LINK)

Example:

await prisma.emailTemplateVariable.create({
  data: {
    templateId: template.id,
    key: 'HAS_PHONE',
    label: 'Has Phone Number',
    description: 'Whether user provided a phone number',
    isRequired: false,
    isConditional: true,  // ← Boolean/truthy variable
    sampleValue: 'true',
    sortOrder: 4,
  },
});

Template Usage:

{{#if HAS_PHONE}}
<p>Contact: {{USER_PHONE}}</p>
{{/if}}

Truthy Values:

  • true, 'true', 1, non-empty strings, non-empty arrays

Falsy Values:

  • false, 'false', 0, '', null, undefined, []

Array Variables (Loops)

Purpose: Collections for {{#each}} blocks.

Behavior:

  • Not explicitly marked (same as other variables)
  • Sample value should be JSON array string
  • Used in {{#each VAR}}...{{/each}} loops

When to Use:

  • Lists of representatives, shift assignments, visit outcomes
  • Dynamic content length (1-N items)

Example:

await prisma.emailTemplateVariable.create({
  data: {
    templateId: template.id,
    key: 'REPRESENTATIVES',
    label: 'Representative List',
    description: 'Array of representative objects',
    isRequired: true,
    isConditional: false,
    sampleValue: JSON.stringify([
      { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },
      { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },
    ]),
    sortOrder: 10,
  },
});

Template Usage:

<ul>
{{#each REPRESENTATIVES}}
  <li>
    <strong>{{name}}</strong> ({{title}})<br>
    Email: {{email}}
  </li>
{{/each}}
</ul>

Data Object:

{
  REPRESENTATIVES: [
    { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },
    { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },
  ],
}

Admin Workflow

Viewing Variables

From EmailTemplatesPage:

  1. Click Template Row

    • Opens template detail modal
  2. Navigate to "Variables" Tab

    • Shows table of all variables
    • Columns: Key, Label, Required, Conditional, Sample Value, Sort Order
  3. Variable Details

    • Click variable row for description
    • See where variable is used in template content
    • View sample value

From EmailTemplateEditorPage:

  1. Open Template Editor

    • Variables shown in right sidebar
  2. Variable Insertion Panel

    • Variables listed with labels, badges, descriptions
    • Sorted by sortOrder ascending
    • Click "Insert to HTML/Text" buttons

Adding Variable

Step 1: Open Variables Tab

  • EmailTemplatesPage → click template → "Variables" tab

Step 2: Click "Add Variable" Button

  • Opens variable creation modal

Step 3: Enter Variable Metadata

Key (required):

  • Uppercase with underscores (e.g., USER_NAME)
  • Must be unique within template
  • Used in template as {{KEY}}

Label (required):

  • Display name for UI (e.g., "User's Full Name")
  • Human-readable description

Description (optional):

  • Detailed explanation of variable purpose
  • Usage notes (e.g., "Must be in YYYY-MM-DD format")

Is Required:

  • Toggle on if variable must always be provided
  • Validation will fail if missing

Is Conditional:

  • Toggle on if variable is used in {{#if}} blocks
  • UI shows blue "Conditional" badge

Sample Value (optional):

  • Example value for testing/preview
  • Pre-fills test send form
  • Shows expected data format

Sort Order:

  • Numeric order for UI display
  • Lower numbers appear first (1, 2, 3...)
  • Auto-assigned if not specified

Step 4: Save Variable

  • Click "Save" button
  • Variable added to template
  • Available in editor insertion panel

Editing Variable

Step 1: Open Variables Tab

  • EmailTemplatesPage → click template → "Variables" tab

Step 2: Click Variable Row

  • Opens variable edit modal
  • Shows current values

Step 3: Modify Fields

  • Change label, description, flags, sample value
  • Cannot change key (would break existing templates)

Step 4: Save Changes

  • Click "Save" button
  • Variable updated in database

Note: Changing variable key requires creating new variable and updating template content manually.


Deleting Variable

Step 1: Check Template Usage

  • Search template content for {{VAR_KEY}}
  • Ensure variable is not used in subject/HTML/text

Step 2: Click Delete Button

  • Variables tab → click variable row → "Delete" button

Step 3: Confirm Deletion

  • Warning modal: "Are you sure? This cannot be undone."
  • Click "Confirm Delete"

Step 4: Verify Template Still Valid

  • Open template editor
  • Check preview renders without errors
  • Send test email

Warning: Deleting a variable that's still used in template content will cause rendering errors ({{VAR}} will appear as literal text).


Reordering Variables

Step 1: Open Variables Tab

  • EmailTemplatesPage → click template → "Variables" tab

Step 2: Drag to Reorder

  • Drag variable rows up/down
  • Drop to new position

Step 3: Save Sort Order

  • Click "Save Order" button
  • Updates sortOrder field for all variables

Alternative: Manual Sort Order

  • Edit variable → change sortOrder number
  • Variables re-sort automatically

Developer Workflow

Creating Variables Programmatically

Seed Script Example:

// api/prisma/seed.ts

const template = await prisma.emailTemplate.findUnique({
  where: { key: 'shift-signup-confirmation' },
});

if (!template) throw new Error('Template not found');

const variables = [
  {
    key: 'USER_NAME',
    label: 'User Name',
    description: 'Full name of the volunteer',
    isRequired: true,
    isConditional: false,
    sampleValue: 'John Doe',
    sortOrder: 1,
  },
  {
    key: 'USER_EMAIL',
    label: 'User Email',
    description: 'Email address of the volunteer',
    isRequired: true,
    isConditional: false,
    sampleValue: 'john@example.com',
    sortOrder: 2,
  },
  {
    key: 'SHIFT_TITLE',
    label: 'Shift Title',
    description: 'Name of the shift',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Door Knocking - Downtown',
    sortOrder: 3,
  },
  {
    key: 'SHIFT_DATE',
    label: 'Shift Date',
    description: 'Formatted shift date (e.g., "Saturday, March 15, 2026")',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Saturday, March 15, 2026',
    sortOrder: 4,
  },
  {
    key: 'SHIFT_TIME',
    label: 'Shift Time',
    description: 'Shift time range (e.g., "10:00 AM - 2:00 PM")',
    isRequired: true,
    isConditional: false,
    sampleValue: '10:00 AM - 2:00 PM',
    sortOrder: 5,
  },
  {
    key: 'SHIFT_LOCATION',
    label: 'Shift Location',
    description: 'Meeting location for shift',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Campaign Office (123 Main St)',
    sortOrder: 6,
  },
  {
    key: 'HAS_PHONE',
    label: 'Has Phone Number',
    description: 'Whether user provided a phone number',
    isRequired: false,
    isConditional: true,
    sampleValue: 'true',
    sortOrder: 7,
  },
  {
    key: 'USER_PHONE',
    label: 'User Phone',
    description: 'User phone number (optional)',
    isRequired: false,
    isConditional: false,
    sampleValue: '(555) 123-4567',
    sortOrder: 8,
  },
];

// Upsert variables
for (const variable of variables) {
  await prisma.emailTemplateVariable.upsert({
    where: {
      templateId_key: {
        templateId: template.id,
        key: variable.key,
      },
    },
    update: {
      label: variable.label,
      description: variable.description,
      isRequired: variable.isRequired,
      isConditional: variable.isConditional,
      sampleValue: variable.sampleValue,
      sortOrder: variable.sortOrder,
    },
    create: {
      templateId: template.id,
      ...variable,
    },
  });
}

console.log(`✓ Created ${variables.length} variables for shift-signup-confirmation template`);

Loading Variables in Code

With Template:

const template = await prisma.emailTemplate.findUnique({
  where: { key: 'shift-signup-confirmation' },
  include: { variables: true },
});

console.log('Template variables:', template?.variables);

Ordered by Sort:

const template = await prisma.emailTemplate.findUnique({
  where: { key: 'shift-signup-confirmation' },
  include: {
    variables: {
      orderBy: { sortOrder: 'asc' },
    },
  },
});

Required Variables Only:

const requiredVars = await prisma.emailTemplateVariable.findMany({
  where: {
    templateId: template.id,
    isRequired: true,
  },
});

console.log('Required variables:', requiredVars.map(v => v.key));

Validating Variables

Validation Function:

// api/src/services/email.service.ts

function validateVariables(
  template: EmailTemplate & { variables: EmailTemplateVariable[] },
  data: Record<string, unknown>
) {
  const missing: string[] = [];

  for (const variable of template.variables) {
    if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {
      missing.push(variable.key);
    }
  }

  if (missing.length > 0) {
    throw new MissingRequiredVariableError(
      `Missing required variables for template ${template.key}: ${missing.join(', ')}`
    );
  }
}

Usage:

const template = await prisma.emailTemplate.findUnique({
  where: { key: 'shift-reminder' },
  include: { variables: true },
});

try {
  validateVariables(template, {
    USER_NAME: 'John Doe',
    SHIFT_DATE: '2026-03-15',
    // Missing SHIFT_TITLE (required)
  });
} catch (error) {
  console.error('Validation failed:', error.message);
  // Error: Missing required variables for template shift-reminder: SHIFT_TITLE
}

Code Examples

Creating Variable via API

Endpoint: POST /api/email-templates/:id/variables

Request Body:

{
  "key": "USER_NAME",
  "label": "User Name",
  "description": "Full name of the email recipient",
  "isRequired": true,
  "isConditional": false,
  "sampleValue": "John Doe",
  "sortOrder": 1
}

Route Implementation:

// api/src/modules/email-templates/email-templates.routes.ts

router.post('/:id/variables', requireRole(SUPER_ADMIN), async (req, res) => {
  const { id } = req.params;
  const { key, label, description, isRequired, isConditional, sampleValue, sortOrder } = req.body;

  try {
    const variable = await prisma.emailTemplateVariable.create({
      data: {
        templateId: id,
        key,
        label,
        description,
        isRequired: isRequired || false,
        isConditional: isConditional || false,
        sampleValue,
        sortOrder: sortOrder || 0,
      },
    });

    res.json(variable);
  } catch (error: any) {
    if (error.code === 'P2002') {
      // Unique constraint violation
      return res.status(400).json({ error: 'Variable key already exists for this template' });
    }
    throw error;
  }
});

Auto-Generating Sample Data

Load Sample Data from Variables:

function generateSampleData(variables: EmailTemplateVariable[]): Record<string, unknown> {
  const sampleData: Record<string, unknown> = {};

  for (const variable of variables) {
    if (variable.sampleValue) {
      // Try to parse as JSON (for arrays/objects)
      try {
        sampleData[variable.key] = JSON.parse(variable.sampleValue);
      } catch {
        // Use as string
        sampleData[variable.key] = variable.sampleValue;
      }
    } else if (variable.isConditional) {
      // Default conditional variables to true
      sampleData[variable.key] = true;
    } else {
      // Default to empty string
      sampleData[variable.key] = '';
    }
  }

  return sampleData;
}

Usage in Editor:

const template = await api.get(`/api/email-templates/${id}`);
const sampleData = generateSampleData(template.variables);

setSampleData(sampleData);

Variable Usage Detection

Find Variables Used in Template Content:

function findUsedVariables(content: string): string[] {
  // Regex: matches {{VAR}} but not {{#if}}, {{/if}}, {{#each}}, etc.
  const regex = /\{\{(?!#|\/|\^)([A-Z_]+)\}\}/g;
  const matches = content.matchAll(regex);

  const variables = new Set<string>();
  for (const match of matches) {
    variables.add(match[1]);
  }

  return Array.from(variables);
}

Check for Unused Variables:

const template = await prisma.emailTemplate.findUnique({
  where: { id: templateId },
  include: { variables: true },
});

const htmlVars = findUsedVariables(template.htmlContent);
const textVars = findUsedVariables(template.textContent);
const subjectVars = findUsedVariables(template.subjectLine);

const usedVars = new Set([...htmlVars, ...textVars, ...subjectVars]);

const unusedVars = template.variables.filter(v => !usedVars.has(v.key));

console.log('Unused variables:', unusedVars.map(v => v.key));

Common Variables by Category

INFLUENCE Templates

Standard Variables:

Key Label Required Conditional Description
USER_NAME User Name Yes No Participant's full name
USER_EMAIL User Email Yes No Participant's email address
CAMPAIGN_TITLE Campaign Title Yes No Campaign name
CAMPAIGN_SLUG Campaign Slug Yes No URL-safe campaign identifier
CAMPAIGN_URL Campaign URL No No Full URL to campaign page
REPRESENTATIVE_NAME Representative Name Yes No Representative's full name
REPRESENTATIVE_TITLE Representative Title Yes No Representative's title (e.g., "MP for Downtown")
REPRESENTATIVE_EMAIL Representative Email Yes No Representative's email address
CUSTOM_MESSAGE Custom Message Yes No Participant's custom message to representative
RESPONSE_TEXT Response Text No No Participant's response wall submission
VERIFICATION_LINK Verification Link No No Unique verification URL
HAS_CUSTOM_MESSAGE Has Custom Message No Yes Whether participant added custom message

Usage Example:

await emailService.sendFromTemplate('campaign-email', {
  recipientEmail: representative.email,
  data: {
    USER_NAME: participant.name,
    USER_EMAIL: participant.email,
    CAMPAIGN_TITLE: campaign.title,
    CAMPAIGN_SLUG: campaign.slug,
    REPRESENTATIVE_NAME: representative.name,
    REPRESENTATIVE_TITLE: representative.title,
    REPRESENTATIVE_EMAIL: representative.email,
    CUSTOM_MESSAGE: emailData.customMessage,
  },
});

MAP Templates

Standard Variables:

Key Label Required Conditional Description
USER_NAME User Name Yes No Volunteer's full name
USER_EMAIL User Email Yes No Volunteer's email address
USER_PHONE User Phone No No Volunteer's phone number (optional)
HAS_PHONE Has Phone No Yes Whether user provided phone number
SHIFT_TITLE Shift Title Yes No Shift name
SHIFT_DATE Shift Date Yes No Formatted shift date
SHIFT_TIME Shift Time Yes No Shift time range (e.g., "10:00 AM - 2:00 PM")
SHIFT_LOCATION Shift Location Yes No Meeting location for shift
CUT_NAME Cut Name No No Canvass area name
IS_CUT_ASSIGNED Is Cut Assigned No Yes Whether volunteer is assigned to a cut
VISIT_COUNT Visit Count No No Number of doors knocked (session summary)
CONTACT_COUNT Contact Count No No Number of successful contacts
SUPPORT_COUNT Support Count No No Number of supporters identified

Usage Example:

await emailService.sendFromTemplate('shift-signup-confirmation', {
  recipientEmail: volunteer.email,
  data: {
    USER_NAME: volunteer.name,
    USER_EMAIL: volunteer.email,
    USER_PHONE: volunteer.phone || '',
    HAS_PHONE: !!volunteer.phone,
    SHIFT_TITLE: shift.title,
    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),
    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,
    SHIFT_LOCATION: shift.location,
    IS_CUT_ASSIGNED: !!shift.cutId,
    CUT_NAME: shift.cut?.name || '',
  },
});

SYSTEM Templates

Standard Variables:

Key Label Required Conditional Description
USER_NAME User Name Yes No User's full name
USER_EMAIL User Email Yes No User's email address
VERIFICATION_LINK Verification Link No No Unique verification URL (expires 24h)
RESET_LINK Reset Link No No Unique password reset URL (expires 1h)
SUPPORT_EMAIL Support Email Yes No Platform support email address
SITE_NAME Site Name Yes No Platform name (from SiteSettings)
SITE_URL Site URL Yes No Platform base URL
LOGIN_URL Login URL No No Direct link to login page
LOCKOUT_REASON Lockout Reason No No Why account was locked (security)

Usage Example:

await emailService.sendFromTemplate('password-reset', {
  recipientEmail: user.email,
  data: {
    USER_NAME: user.name,
    USER_EMAIL: user.email,
    RESET_LINK: `https://cmlite.org/reset-password/${token}`,
    SUPPORT_EMAIL: siteSettings.supportEmail,
    SITE_NAME: siteSettings.siteName,
    SITE_URL: siteSettings.siteUrl,
  },
});

Troubleshooting

Problem: Variable not appearing in editor

Symptoms:

  • Variable exists in database but not shown in editor insertion panel
  • Variable missing from variables list

Causes:

  1. Variable belongs to different template
  2. Template not refreshed after adding variable
  3. Sort order is null or very high (out of view)

Solutions:

Check variable exists:

SELECT * FROM email_template_variables
WHERE template_id = 'cuid123' AND key = 'USER_NAME';

Verify template ID:

SELECT id, key FROM email_templates WHERE key = 'shift-reminder';
-- Check ID matches variable.template_id

Refresh editor page:

  • Hard refresh (Ctrl+Shift+R)
  • Clear browser cache

Check sort order:

SELECT key, sort_order FROM email_template_variables
WHERE template_id = 'cuid123'
ORDER BY sort_order;

-- Update if needed
UPDATE email_template_variables
SET sort_order = 1
WHERE id = 'variable-id';

Problem: Validation error for optional variable

Symptoms:

  • MissingRequiredVariableError thrown for variable marked as optional
  • Email send fails unexpectedly

Causes:

  1. Variable incorrectly marked as required in database
  2. Validation logic bug
  3. Template uses variable in required context

Solutions:

Check isRequired flag:

SELECT key, is_required FROM email_template_variables
WHERE key = 'USER_PHONE' AND template_id = 'cuid123';

Update to optional:

UPDATE email_template_variables
SET is_required = false
WHERE key = 'USER_PHONE' AND template_id = 'cuid123';

Provide variable anyway:

// Temporary fix: always provide optional variables
data: {
  USER_PHONE: volunteer.phone || '',  // Empty string if missing
}

Check validation logic:

// Ensure validation checks for undefined AND null
if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {
  missing.push(variable.key);
}

Problem: Sample value not used in preview

Symptoms:

  • Preview shows empty values instead of sample values
  • Test send form doesn't pre-fill

Causes:

  1. Sample value is null in database
  2. Sample data initialization bug
  3. Variable added after editor loaded

Solutions:

Check sample value exists:

SELECT key, sample_value FROM email_template_variables
WHERE template_id = 'cuid123';

Update sample value:

UPDATE email_template_variables
SET sample_value = 'John Doe'
WHERE key = 'USER_NAME';

Refresh editor:

  • Close and reopen EmailTemplateEditorPage
  • Sample data reloads from variables

Manual preview data:

// Editor UI allows manual editing of sample data
setSampleData({
  ...sampleData,
  USER_NAME: 'Test Name',
});

Problem: Duplicate variable key error

Symptoms:

  • P2002: Unique constraint failed error when creating variable
  • Cannot add variable with same key

Causes:

  1. Variable already exists for this template
  2. Attempting to create duplicate

Solutions:

Check existing variables:

SELECT * FROM email_template_variables
WHERE template_id = 'cuid123' AND key = 'USER_NAME';

Update existing instead:

await prisma.emailTemplateVariable.upsert({
  where: {
    templateId_key: {
      templateId: template.id,
      key: 'USER_NAME',
    },
  },
  update: {
    label: 'User Full Name',  // Updated label
  },
  create: {
    templateId: template.id,
    key: 'USER_NAME',
    label: 'User Full Name',
    // ...
  },
});

Use different key:

// If truly need separate variable
key: 'USER_FULL_NAME',  // Not USER_NAME

Problem: Variables not alphabetically sorted

Symptoms:

  • Variables appear in random order in editor
  • Want alphabetical order instead of custom sort

Causes:

  • Sort order not set alphabetically
  • Need to update sortOrder values

Solutions:

Sort alphabetically by key:

-- Generate new sort order based on alphabetical order
WITH sorted AS (
  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY key) AS new_order
  FROM email_template_variables
  WHERE template_id = 'cuid123'
)
UPDATE email_template_variables
SET sort_order = sorted.new_order
FROM sorted
WHERE email_template_variables.id = sorted.id;

Sort by label:

WITH sorted AS (
  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY label) AS new_order
  FROM email_template_variables
  WHERE template_id = 'cuid123'
)
UPDATE email_template_variables
SET sort_order = sorted.new_order
FROM sorted
WHERE email_template_variables.id = sorted.id;

Manual custom order:

  • Use admin UI to drag-drop reorder
  • Saves custom sortOrder values

Performance Considerations

Variable Loading

Current Implementation:

  • Variables loaded with template via include: { variables: true }
  • Single database query (JOIN)
  • Fast (< 10ms for typical templates)

Optimization for Many Variables:

// If template has 100+ variables, consider pagination
const variables = await prisma.emailTemplateVariable.findMany({
  where: { templateId: template.id },
  orderBy: { sortOrder: 'asc' },
  take: 50,  // Load first 50
  skip: 0,   // Offset for pagination
});

Validation Performance

Required Variable Check:

  • O(n) where n = number of required variables
  • Fast for typical templates (< 10 required vars)
  • No database queries (uses in-memory variable list)

Caching Variables:

// Cache template + variables to avoid DB lookup per send
const templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();

async function loadTemplate(key: string) {
  if (templateCache.has(key)) {
    return templateCache.get(key)!;
  }

  const template = await prisma.emailTemplate.findUnique({
    where: { key, isActive: true },
    include: { variables: true },
  });

  if (template) {
    templateCache.set(key, template);
  }

  return template;
}

Best Practices

Variable Naming Conventions

Use UPPERCASE_WITH_UNDERSCORES:

// ✓ Good
USER_NAME
SHIFT_DATE
HAS_PHONE
REPRESENTATIVE_EMAIL

// ✗ Bad
userName      // Not uppercase
user-name     // Dashes not underscores
UserName      // PascalCase

Be Descriptive:

// ✓ Good
SHIFT_START_TIME
CAMPAIGN_TITLE
IS_EMAIL_VERIFIED

// ✗ Bad
TIME          // Too vague
TITLE         // Ambiguous
VERIFIED      // Missing context

Prefix Booleans with IS/HAS:

// ✓ Good
HAS_PHONE
IS_VERIFIED
IS_CUT_ASSIGNED

// ✗ Bad
PHONE         // Not clearly boolean
VERIFIED      // Ambiguous (boolean or timestamp?)

Documentation

Always Provide Labels:

// ✓ Good
label: 'User\'s Full Name',
description: 'Full name of the email recipient',

// ✗ Bad
label: 'Name',  // Too generic
description: '',

Document Expected Format:

// ✓ Good
description: 'Shift date in format "Saturday, March 15, 2026"',
sampleValue: 'Saturday, March 15, 2026',

// ✗ Bad
description: 'The date',
sampleValue: '2026-03-15',  // Doesn't match expected format

Sample Values

Provide Realistic Examples:

// ✓ Good
sampleValue: 'John Doe',                      // USER_NAME
sampleValue: 'Saturday, March 15, 2026',      // SHIFT_DATE
sampleValue: '(555) 123-4567',                // USER_PHONE

// ✗ Bad
sampleValue: 'test',                          // Not realistic
sampleValue: '123',                           // Not realistic phone

Use JSON for Arrays/Objects:

// ✓ Good
sampleValue: JSON.stringify([
  { name: 'Jane Doe', email: 'jane@example.com' },
  { name: 'John Smith', email: 'john@example.com' },
]),

// ✗ Bad
sampleValue: 'array of representatives',  // Not parseable

Required vs Optional

Make Variables Required If:

  • Used in subject line (always visible)
  • Critical to email meaning (e.g., event date)
  • No reasonable default value

Make Variables Optional If:

  • Used in conditional blocks ({{#if}})
  • Nice-to-have but not critical
  • Has fallback text in template

Frontend Documentation

Backend Documentation

  • Email Templates Module — Variable CRUD API
    • GET /api/email-templates/:id/variables — List variables
    • POST /api/email-templates/:id/variables — Create variable
    • PUT /api/email-templates/:id/variables/:varId — Update variable
    • DELETE /api/email-templates/:id/variables/:varId — Delete variable

Database Documentation

Feature Documentation