# 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
```mermaid
flowchart TB
subgraph "Database Layer"
Template[(EmailTemplate)]
Variables[(EmailTemplateVariable)]
Template -->|1:N| Variables
end
subgraph "Variable Definition"
VarKey[Variable Key
USER_NAME]
VarMeta[Metadata
label, description, isRequired]
VarSample[Sample Value
'John Doe']
VarSort[Sort Order
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
Variables
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:**
```prisma
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:**
```typescript
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:**
```html
Dear {{USER_NAME}},
```
---
### 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:**
```typescript
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:**
```html
{{#if USER_PHONE}}
We'll call you at {{USER_PHONE}}.
{{else}}
Add a phone number to receive SMS updates.
{{/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:**
```typescript
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:**
```html
{{#if HAS_PHONE}}
Contact: {{USER_PHONE}}
{{/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:**
```typescript
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:**
```html
{{#each REPRESENTATIVES}}
-
{{name}} ({{title}})
Email: {{email}}
{{/each}}
```
**Data Object:**
```typescript
{
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:**
```typescript
// 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:**
```typescript
const template = await prisma.emailTemplate.findUnique({
where: { key: 'shift-signup-confirmation' },
include: { variables: true },
});
console.log('Template variables:', template?.variables);
```
**Ordered by Sort:**
```typescript
const template = await prisma.emailTemplate.findUnique({
where: { key: 'shift-signup-confirmation' },
include: {
variables: {
orderBy: { sortOrder: 'asc' },
},
},
});
```
**Required Variables Only:**
```typescript
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:**
```typescript
// api/src/services/email.service.ts
function validateVariables(
template: EmailTemplate & { variables: EmailTemplateVariable[] },
data: Record
) {
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:**
```typescript
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:**
```json
{
"key": "USER_NAME",
"label": "User Name",
"description": "Full name of the email recipient",
"isRequired": true,
"isConditional": false,
"sampleValue": "John Doe",
"sortOrder": 1
}
```
**Route Implementation:**
```typescript
// 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:**
```typescript
function generateSampleData(variables: EmailTemplateVariable[]): Record {
const sampleData: Record = {};
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:**
```typescript
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:**
```typescript
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();
for (const match of matches) {
variables.add(match[1]);
}
return Array.from(variables);
}
```
**Check for Unused Variables:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```sql
SELECT * FROM email_template_variables
WHERE template_id = 'cuid123' AND key = 'USER_NAME';
```
**Verify template ID:**
```sql
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:**
```sql
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:**
```sql
SELECT key, is_required FROM email_template_variables
WHERE key = 'USER_PHONE' AND template_id = 'cuid123';
```
**Update to optional:**
```sql
UPDATE email_template_variables
SET is_required = false
WHERE key = 'USER_PHONE' AND template_id = 'cuid123';
```
**Provide variable anyway:**
```typescript
// Temporary fix: always provide optional variables
data: {
USER_PHONE: volunteer.phone || '', // Empty string if missing
}
```
**Check validation logic:**
```typescript
// 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:**
```sql
SELECT key, sample_value FROM email_template_variables
WHERE template_id = 'cuid123';
```
**Update sample value:**
```sql
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:**
```typescript
// 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:**
```sql
SELECT * FROM email_template_variables
WHERE template_id = 'cuid123' AND key = 'USER_NAME';
```
**Update existing instead:**
```typescript
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:**
```typescript
// 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:**
```sql
-- 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:**
```sql
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:**
```typescript
// 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:**
```typescript
// Cache template + variables to avoid DB lookup per send
const templateCache = new Map();
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:**
```typescript
// ✓ Good
USER_NAME
SHIFT_DATE
HAS_PHONE
REPRESENTATIVE_EMAIL
// ✗ Bad
userName // Not uppercase
user-name // Dashes not underscores
UserName // PascalCase
```
**Be Descriptive:**
```typescript
// ✓ Good
SHIFT_START_TIME
CAMPAIGN_TITLE
IS_EMAIL_VERIFIED
// ✗ Bad
TIME // Too vague
TITLE // Ambiguous
VERIFIED // Missing context
```
**Prefix Booleans with IS/HAS:**
```typescript
// ✓ Good
HAS_PHONE
IS_VERIFIED
IS_CUT_ASSIGNED
// ✗ Bad
PHONE // Not clearly boolean
VERIFIED // Ambiguous (boolean or timestamp?)
```
---
### Documentation
**Always Provide Labels:**
```typescript
// ✓ Good
label: 'User\'s Full Name',
description: 'Full name of the email recipient',
// ✗ Bad
label: 'Name', // Too generic
description: '',
```
**Document Expected Format:**
```typescript
// ✓ 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:**
```typescript
// ✓ 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:**
```typescript
// ✓ 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
---
## Related Documentation
### Frontend Documentation
- **[EmailTemplateEditorPage.tsx](../../frontend/pages/email-template-editor-page.md)** — Variable insertion panel
- **[EmailTemplatesPage.tsx](../../frontend/pages/email-templates-page.md)** — Variables tab
### Backend Documentation
- **[Email Templates Module](../../api/modules/email-templates.md)** — 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
- **[Email Templates Models](../../database/models/email-templates.md)** — EmailTemplateVariable schema
### Feature Documentation
- **[template-system.md](./template-system.md)** — Email template engine overview
- **[editor.md](./editor.md)** — Email template editor interface
- **[versioning.md](./versioning.md)** — Template version history