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 { 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(); // 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();