558 lines
16 KiB
TypeScript
558 lines
16 KiB
TypeScript
import { PrismaClient, Prisma } from '@prisma/client';
|
|
import type {
|
|
ListEmailTemplatesDto,
|
|
CreateEmailTemplateDto,
|
|
UpdateEmailTemplateDto,
|
|
RollbackToVersionDto,
|
|
ValidateTemplateDto,
|
|
SendTestEmailDto,
|
|
} from './email-templates.schemas';
|
|
import { logger } from '../../utils/logger';
|
|
import { emailService } from '../../services/email.service';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
interface EmailTemplatesListResponse {
|
|
templates: any[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
}
|
|
|
|
interface ValidationResult {
|
|
valid: boolean;
|
|
errors: string[];
|
|
warnings?: string[];
|
|
extractedVariables?: string[];
|
|
}
|
|
|
|
export class EmailTemplatesService {
|
|
/**
|
|
* List email templates with pagination, search, and filters
|
|
*/
|
|
async list(params: ListEmailTemplatesDto): Promise<EmailTemplatesListResponse> {
|
|
const { page, limit, search, category, isActive } = params;
|
|
const skip = (page - 1) * limit;
|
|
|
|
// Build where clause
|
|
const where: Prisma.EmailTemplateWhereInput = {
|
|
...(search && {
|
|
OR: [
|
|
{ key: { contains: search, mode: 'insensitive' as Prisma.QueryMode } },
|
|
{ name: { contains: search, mode: 'insensitive' as Prisma.QueryMode } },
|
|
{ description: { contains: search, mode: 'insensitive' as Prisma.QueryMode } },
|
|
],
|
|
}),
|
|
...(category && { category }),
|
|
...(isActive !== undefined && { isActive }),
|
|
};
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.emailTemplate.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy: [{ isSystem: 'desc' }, { category: 'asc' }, { name: 'asc' }],
|
|
include: {
|
|
variables: {
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
_count: {
|
|
select: {
|
|
versions: true,
|
|
testLogs: true,
|
|
},
|
|
},
|
|
createdBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
updatedBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
}),
|
|
prisma.emailTemplate.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
templates: data,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a single email template by ID
|
|
*/
|
|
async getById(id: string) {
|
|
const template = await prisma.emailTemplate.findUnique({
|
|
where: { id },
|
|
include: {
|
|
variables: {
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
_count: {
|
|
select: {
|
|
versions: true,
|
|
testLogs: true,
|
|
},
|
|
},
|
|
createdBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
updatedBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!template) {
|
|
throw new Error('Template not found');
|
|
}
|
|
|
|
return template;
|
|
}
|
|
|
|
/**
|
|
* Get template by key
|
|
*/
|
|
async getByKey(key: string) {
|
|
const template = await prisma.emailTemplate.findUnique({
|
|
where: { key },
|
|
include: {
|
|
variables: {
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
|
|
return template;
|
|
}
|
|
|
|
/**
|
|
* Create a new email template
|
|
*/
|
|
async create(data: CreateEmailTemplateDto, userId: string) {
|
|
// Check for duplicate key
|
|
const existing = await prisma.emailTemplate.findUnique({
|
|
where: { key: data.key },
|
|
});
|
|
|
|
if (existing) {
|
|
throw new Error(`Template with key "${data.key}" already exists`);
|
|
}
|
|
|
|
// Validate template content
|
|
const validation = this.validateTemplate({
|
|
htmlContent: data.htmlContent,
|
|
textContent: data.textContent,
|
|
subjectLine: data.subjectLine,
|
|
});
|
|
|
|
if (!validation.valid) {
|
|
throw new Error(`Template validation failed: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
// Create template with variables and initial version in transaction
|
|
const template = await prisma.$transaction(async (tx) => {
|
|
const newTemplate = await tx.emailTemplate.create({
|
|
data: {
|
|
key: data.key,
|
|
name: data.name,
|
|
description: data.description,
|
|
category: data.category,
|
|
subjectLine: data.subjectLine,
|
|
htmlContent: data.htmlContent,
|
|
textContent: data.textContent,
|
|
isSystem: false, // User-created templates are never system templates
|
|
isActive: data.isActive ?? true,
|
|
createdByUserId: userId,
|
|
variables: data.variables
|
|
? {
|
|
create: data.variables,
|
|
}
|
|
: undefined,
|
|
},
|
|
include: {
|
|
variables: true,
|
|
},
|
|
});
|
|
|
|
// Create initial version
|
|
await tx.emailTemplateVersion.create({
|
|
data: {
|
|
templateId: newTemplate.id,
|
|
versionNumber: 1,
|
|
subjectLine: data.subjectLine,
|
|
htmlContent: data.htmlContent,
|
|
textContent: data.textContent,
|
|
changeNotes: 'Initial version',
|
|
createdByUserId: userId,
|
|
},
|
|
});
|
|
|
|
return newTemplate;
|
|
});
|
|
|
|
logger.info(`Email template created: ${template.key} by user ${userId}`);
|
|
|
|
// Clear email service cache for this template
|
|
emailService.clearDatabaseCache(template.key);
|
|
|
|
return template;
|
|
}
|
|
|
|
/**
|
|
* Update an email template
|
|
*/
|
|
async update(id: string, data: UpdateEmailTemplateDto, userId: string) {
|
|
const existing = await this.getById(id);
|
|
|
|
// Validate template content if provided
|
|
if (data.htmlContent || data.textContent) {
|
|
const validation = this.validateTemplate({
|
|
htmlContent: data.htmlContent ?? existing.htmlContent,
|
|
textContent: data.textContent ?? existing.textContent,
|
|
subjectLine: data.subjectLine ?? existing.subjectLine,
|
|
});
|
|
|
|
if (!validation.valid) {
|
|
throw new Error(`Template validation failed: ${validation.errors.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Update template and create new version if content changed
|
|
const template = await prisma.$transaction(async (tx) => {
|
|
// Update template
|
|
const updated = await tx.emailTemplate.update({
|
|
where: { id },
|
|
data: {
|
|
...(data.name && { name: data.name }),
|
|
...(data.description !== undefined && { description: data.description }),
|
|
...(data.category && { category: data.category }),
|
|
...(data.subjectLine && { subjectLine: data.subjectLine }),
|
|
...(data.htmlContent && { htmlContent: data.htmlContent }),
|
|
...(data.textContent && { textContent: data.textContent }),
|
|
...(data.isActive !== undefined && { isActive: data.isActive }),
|
|
updatedByUserId: userId,
|
|
// Handle variables update
|
|
...(data.variables && {
|
|
variables: {
|
|
deleteMany: {},
|
|
create: data.variables,
|
|
},
|
|
}),
|
|
},
|
|
include: {
|
|
variables: true,
|
|
},
|
|
});
|
|
|
|
// Create new version if content changed
|
|
if (data.subjectLine || data.htmlContent || data.textContent) {
|
|
const latestVersion = await tx.emailTemplateVersion.findFirst({
|
|
where: { templateId: id },
|
|
orderBy: { versionNumber: 'desc' },
|
|
});
|
|
|
|
const nextVersionNumber = (latestVersion?.versionNumber ?? 0) + 1;
|
|
|
|
await tx.emailTemplateVersion.create({
|
|
data: {
|
|
templateId: id,
|
|
versionNumber: nextVersionNumber,
|
|
subjectLine: data.subjectLine ?? existing.subjectLine,
|
|
htmlContent: data.htmlContent ?? existing.htmlContent,
|
|
textContent: data.textContent ?? existing.textContent,
|
|
changeNotes: `Updated via admin interface`,
|
|
createdByUserId: userId,
|
|
},
|
|
});
|
|
|
|
logger.info(`Created version ${nextVersionNumber} for template ${existing.key}`);
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
|
|
logger.info(`Email template updated: ${existing.key} by user ${userId}`);
|
|
|
|
// Clear email service cache
|
|
emailService.clearDatabaseCache(existing.key);
|
|
|
|
return template;
|
|
}
|
|
|
|
/**
|
|
* Delete an email template
|
|
*/
|
|
async delete(id: string) {
|
|
const existing = await this.getById(id);
|
|
|
|
if (existing.isSystem) {
|
|
throw new Error('Cannot delete system templates');
|
|
}
|
|
|
|
await prisma.emailTemplate.delete({
|
|
where: { id },
|
|
});
|
|
|
|
logger.info(`Email template deleted: ${existing.key}`);
|
|
|
|
// Clear email service cache
|
|
emailService.clearDatabaseCache(existing.key);
|
|
}
|
|
|
|
/**
|
|
* Get version history for a template
|
|
*/
|
|
async getVersions(templateId: string) {
|
|
const versions = await prisma.emailTemplateVersion.findMany({
|
|
where: { templateId },
|
|
orderBy: { versionNumber: 'desc' },
|
|
include: {
|
|
createdBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return versions;
|
|
}
|
|
|
|
/**
|
|
* Get a specific version
|
|
*/
|
|
async getVersion(templateId: string, versionNumber: number) {
|
|
const version = await prisma.emailTemplateVersion.findUnique({
|
|
where: {
|
|
templateId_versionNumber: {
|
|
templateId,
|
|
versionNumber,
|
|
},
|
|
},
|
|
include: {
|
|
createdBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!version) {
|
|
throw new Error('Version not found');
|
|
}
|
|
|
|
return version;
|
|
}
|
|
|
|
/**
|
|
* Rollback to a previous version
|
|
*/
|
|
async rollbackToVersion(templateId: string, data: RollbackToVersionDto, userId: string) {
|
|
const template = await this.getById(templateId);
|
|
const targetVersion = await this.getVersion(templateId, data.versionNumber);
|
|
|
|
// Create new version with content from target version
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const latestVersion = await tx.emailTemplateVersion.findFirst({
|
|
where: { templateId },
|
|
orderBy: { versionNumber: 'desc' },
|
|
});
|
|
|
|
const nextVersionNumber = (latestVersion?.versionNumber ?? 0) + 1;
|
|
|
|
// Update template to target version content
|
|
const updated = await tx.emailTemplate.update({
|
|
where: { id: templateId },
|
|
data: {
|
|
subjectLine: targetVersion.subjectLine,
|
|
htmlContent: targetVersion.htmlContent,
|
|
textContent: targetVersion.textContent,
|
|
updatedByUserId: userId,
|
|
},
|
|
});
|
|
|
|
// Create new version (rollback creates a new version, doesn't revert history)
|
|
await tx.emailTemplateVersion.create({
|
|
data: {
|
|
templateId,
|
|
versionNumber: nextVersionNumber,
|
|
subjectLine: targetVersion.subjectLine,
|
|
htmlContent: targetVersion.htmlContent,
|
|
textContent: targetVersion.textContent,
|
|
changeNotes: data.changeNotes ?? `Rolled back to version ${data.versionNumber}`,
|
|
createdByUserId: userId,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
logger.info(`Template ${template.key} rolled back to version ${data.versionNumber} by user ${userId}`);
|
|
|
|
// Clear email service cache
|
|
emailService.clearDatabaseCache(template.key);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Validate template syntax
|
|
*/
|
|
validateTemplate(data: ValidateTemplateDto): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
const variables = new Set<string>();
|
|
|
|
// Extract variables from content
|
|
const variableRegex = /\{\{([A-Z_]+)\}\}/g;
|
|
const conditionalRegex = /\{\{#if\s+([A-Z_]+)\}\}|\{\{\/if\}\}/g;
|
|
|
|
// Check HTML content
|
|
let match;
|
|
while ((match = variableRegex.exec(data.htmlContent)) !== null) {
|
|
variables.add(match[1]);
|
|
}
|
|
while ((match = conditionalRegex.exec(data.htmlContent)) !== null) {
|
|
if (match[1]) variables.add(match[1]);
|
|
}
|
|
|
|
// Check text content
|
|
while ((match = variableRegex.exec(data.textContent)) !== null) {
|
|
variables.add(match[1]);
|
|
}
|
|
while ((match = conditionalRegex.exec(data.textContent)) !== null) {
|
|
if (match[1]) variables.add(match[1]);
|
|
}
|
|
|
|
// Check subject line if provided
|
|
if (data.subjectLine) {
|
|
while ((match = variableRegex.exec(data.subjectLine)) !== null) {
|
|
variables.add(match[1]);
|
|
}
|
|
}
|
|
|
|
// Check for unmatched conditionals
|
|
const ifCount = (data.htmlContent.match(/\{\{#if/g) || []).length;
|
|
const endifCount = (data.htmlContent.match(/\{\{\/if\}\}/g) || []).length;
|
|
if (ifCount !== endifCount) {
|
|
errors.push('Unmatched {{#if}} conditional blocks in HTML content');
|
|
}
|
|
|
|
const ifCountText = (data.textContent.match(/\{\{#if/g) || []).length;
|
|
const endifCountText = (data.textContent.match(/\{\{\/if\}\}/g) || []).length;
|
|
if (ifCountText !== endifCountText) {
|
|
errors.push('Unmatched {{#if}} conditional blocks in text content');
|
|
}
|
|
|
|
// Warn if no variables found
|
|
if (variables.size === 0) {
|
|
warnings.push('No template variables found');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
extractedVariables: Array.from(variables),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send test email
|
|
*/
|
|
async sendTestEmail(templateId: string, data: SendTestEmailDto, userId: string) {
|
|
const template = await this.getById(templateId);
|
|
|
|
try {
|
|
// Process template with test data
|
|
let htmlContent = template.htmlContent;
|
|
let textContent = template.textContent;
|
|
let subjectLine = template.subjectLine;
|
|
|
|
// Replace variables
|
|
for (const [key, value] of Object.entries(data.testData)) {
|
|
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
htmlContent = htmlContent.replace(regex, value);
|
|
textContent = textContent.replace(regex, value);
|
|
subjectLine = subjectLine.replace(regex, value);
|
|
}
|
|
|
|
// Handle conditionals (simple implementation)
|
|
for (const [key, value] of Object.entries(data.testData)) {
|
|
const ifRegex = new RegExp(`\\{\\{#if\\s+${key}\\}\\}([\\s\\S]*?)\\{\\{\\/if\\}\\}`, 'g');
|
|
const shouldShow = value && value !== 'false' && value !== '0';
|
|
htmlContent = htmlContent.replace(ifRegex, shouldShow ? '$1' : '');
|
|
textContent = textContent.replace(ifRegex, shouldShow ? '$1' : '');
|
|
}
|
|
|
|
// Send email via email service
|
|
const result = await emailService.sendEmail({
|
|
to: data.recipientEmail,
|
|
subject: subjectLine,
|
|
text: textContent,
|
|
html: htmlContent,
|
|
});
|
|
|
|
// Log test email
|
|
await prisma.emailTemplateTestLog.create({
|
|
data: {
|
|
templateId,
|
|
recipientEmail: data.recipientEmail,
|
|
testData: data.testData as Prisma.InputJsonValue,
|
|
success: true,
|
|
messageId: result.messageId,
|
|
sentByUserId: userId,
|
|
},
|
|
});
|
|
|
|
logger.info(`Test email sent for template ${template.key} to ${data.recipientEmail}`);
|
|
|
|
return { success: true, messageId: result.messageId };
|
|
} catch (error) {
|
|
// Log failed test
|
|
await prisma.emailTemplateTestLog.create({
|
|
data: {
|
|
templateId,
|
|
recipientEmail: data.recipientEmail,
|
|
testData: data.testData as Prisma.InputJsonValue,
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
sentByUserId: userId,
|
|
},
|
|
});
|
|
|
|
logger.error(`Test email failed for template ${template.key}: ${error}`);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get test logs for a template
|
|
*/
|
|
async getTestLogs(templateId: string, limit = 10) {
|
|
const logs = await prisma.emailTemplateTestLog.findMany({
|
|
where: { templateId },
|
|
orderBy: { sentAt: 'desc' },
|
|
take: limit,
|
|
include: {
|
|
sentBy: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return logs;
|
|
}
|
|
}
|
|
|
|
export const emailTemplatesService = new EmailTemplatesService();
|