diff options
Diffstat (limited to 'lib/email-template/service.ts')
| -rw-r--r-- | lib/email-template/service.ts | 899 |
1 files changed, 899 insertions, 0 deletions
diff --git a/lib/email-template/service.ts b/lib/email-template/service.ts new file mode 100644 index 00000000..13aba77b --- /dev/null +++ b/lib/email-template/service.ts @@ -0,0 +1,899 @@ +'use server'; + +import db from '@/db/db'; +import { eq, and, desc, sql, count, ilike, asc, or } from 'drizzle-orm'; +import handlebars from 'handlebars'; +import i18next from 'i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; +import { getOptions } from '@/i18n/settings'; + +// Schema imports +import { + templateListView, + templateDetailView, + type TemplateListView, + type TemplateDetailView +} from '@/db/schema'; +import { + templates, + templateVariables, + templateHistory, + type Template, + type TemplateVariable, + type TemplateHistory +} from '@/db/schema/templates'; + +// Validation imports +import { GetEmailTemplateSchema } from './validations'; +import { filterColumns } from '../filter-columns'; + +// =========================================== +// Types +// =========================================== + +export interface TemplateWithVariables extends Template { + variables: TemplateVariable[]; +} + +interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +// =========================================== +// Security Constants & Functions +// =========================================== + +const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with', 'eq', 'ne']; + +const DANGEROUS_PATTERNS = [ + /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, + /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, + /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi, + /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi, + /\{\{\s*.*process\s*\..*\}\}/gi, + /\{\{\s*.*global\s*\..*\}\}/gi, + /\{\{\s*.*this\s*\..*\}\}/gi, + /\{\{\s*#with\s+.*\.\.\s*\}\}/gi, +]; + +function validateTemplateContent(content: string, allowedVariables: string[] = []): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // 위험한 패턴 검사 + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(content)) { + errors.push('보안상 위험한 구문이 감지되었습니다.'); + break; + } + } + + // 허용된 변수 검사 + if (allowedVariables.length > 0) { + const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g); + if (variableMatches) { + const usedVariables = new Set<string>(); + for (const match of variableMatches) { + const variable = match.replace(/\{\{\s*|\s*\}\}/g, ''); + usedVariables.add(variable); + } + + for (const variable of usedVariables) { + if (!allowedVariables.includes(variable)) { + errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`); + } + } + } + } + + // 허용된 헬퍼 검사 + const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g); + if (helperMatches) { + const usedHelpers = new Set<string>(); + for (const match of helperMatches) { + const helper = match.replace(/\{\{\s*#/, ''); + usedHelpers.add(helper); + } + + for (const helper of usedHelpers) { + if (!ALLOWED_HELPERS.includes(helper)) { + errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`); + } + } + } + + // HTML 출력 검사 + const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g); + if (htmlOutputMatches) { + warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.'); + } + + return { isValid: errors.length === 0, errors, warnings }; +} + +function sanitizeTripleBraces(content: string): string { + return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => { + return `{{${variable.trim()}}}`; + }); +} + +function preprocessTemplate(content: string): string { + let processed = sanitizeTripleBraces(content); + processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, ''); + processed = processed.replace(/\s+/g, ' ').trim(); + return processed; +} + +function createSafeContext(data: Record<string, any>, allowedVariables: string[]): Record<string, any> { + const safeContext: Record<string, any> = {}; + + for (const key of allowedVariables) { + if (key in data) { + const value = data[key]; + if (typeof value === 'string') { + safeContext[key] = value.replace(/<[^>]*>/g, ''); + } else if (typeof value === 'number' || typeof value === 'boolean') { + safeContext[key] = value; + } else { + safeContext[key] = String(value); + } + } + } + + return safeContext; +} + +// =========================================== +// Handlebars Initialization +// =========================================== + +let initialized = false; + +async function initializeAsync(): Promise<void> { + if (initialized) return; + + try { + if (!i18next.isInitialized) { + await i18next + .use(resourcesToBackend((language: string, namespace: string) => + import(`@/i18n/locales/${language}/${namespace}.json`) + )) + .init(getOptions()); + } + + registerHandlebarsHelpers(); + initialized = true; + } catch (error) { + console.error('Failed to initialize TemplateService:', error); + registerHandlebarsHelpers(); + initialized = true; + } +} + +async function ensureInitialized(): Promise<void> { + if (initialized) return; + await initializeAsync(); +} + +function registerHandlebarsHelpers(): void { + handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) { + return i18next.t(key, options.hash || {}); + }); + + handlebars.registerHelper('formatDate', function(date: Date | string, format?: string) { + if (!date) return ''; + const d = new Date(date); + if (isNaN(d.getTime())) return ''; + + if (!format || format === 'date') { + return d.toISOString().split('T')[0]; + } + + if (format === 'datetime') { + return d.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + + return d.toLocaleDateString('ko-KR'); + }); + + handlebars.registerHelper('formatNumber', function(number: number | string) { + if (typeof number === 'string') { + number = parseFloat(number); + } + if (isNaN(number)) return ''; + return number.toLocaleString('ko-KR'); + }); + + handlebars.registerHelper('ifEquals', function(this: unknown, arg1: unknown, arg2: unknown, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) { + return (arg1 === arg2) ? options.fn(this) : options.inverse(this); + }); + + handlebars.registerHelper('ifArrayLength', function(this: unknown, array: unknown[], length: number, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) { + return (Array.isArray(array) && array.length === length) ? options.fn(this) : options.inverse(this); + }); +} + +// =========================================== +// Core Service Functions +// =========================================== + +/** + * 템플릿 목록 조회 (View 테이블 사용) + */ +export async function getTemplateList(input: GetEmailTemplateSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + const advancedWhere = filterColumns({ + table: templateListView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 조건 구성 + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(templateListView.name, s), + ilike(templateListView.slug, s), + ilike(templateListView.description, s), + ilike(templateListView.category, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + // 정렬 조건 구성 + let orderBy; + try { + orderBy = input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string" || !(item.id in templateListView)) return null; + const col = templateListView[item.id as keyof typeof templateListView]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [desc(templateListView.updatedAt)]; // 기본값: 최신 수정일 순 + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [desc(templateListView.updatedAt)]; + } + + // 데이터 조회 + const data = await db + .select() + .from(templateListView) + .where(where) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(templateListView) + .where(where); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount + }; + } catch (error) { + console.error('Error getting template list:', error); + return { + data: [], + pageCount: 0 + }; + } +} + +/** + * 특정 템플릿 조회 (slug 기준, 변수 포함) + */ +export async function getTemplate(slug: string): Promise<TemplateWithVariables | null> { + try { + // 템플릿 기본 정보 조회 + const [template] = await db + .select() + .from(templates) + .where(and(eq(templates.slug, slug), eq(templates.isActive, true))) + .limit(1); + + if (!template) { + return null; + } + + // 템플릿 변수들 조회 + const variables = await db + .select() + .from(templateVariables) + .where(eq(templateVariables.templateId, template.id)) + .orderBy(templateVariables.displayOrder); + + return { + ...template, + variables + }; + } catch (error) { + console.error('Error getting template:', error); + throw new Error('템플릿을 가져오는데 실패했습니다.'); + } +} + +/** + * 템플릿 생성 + */ +export async function createTemplate(data: { + name: string; + slug: string; + subject: string; + content: string; + category?: string; + description?: string; + sampleData?: Record<string, any>; + createdBy: number; + variables?: Array<{ + variableName: string; + variableType: string; + defaultValue?: string; + isRequired?: boolean; + description?: string; + }>; +}): Promise<TemplateWithVariables> { + try { + // slug 중복 확인 + const existingTemplate = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.slug, data.slug)) + .limit(1); + + if (existingTemplate.length > 0) { + throw new Error('이미 존재하는 slug입니다.'); + } + + // 템플릿 생성 + const [newTemplate] = await db + .insert(templates) + .values({ + name: data.name, + slug: data.slug, + subject: data.subject, + content: data.content, + category: data.category, + description: data.description, + sampleData: data.sampleData || {}, + createdBy: data.createdBy, + }) + .returning(); + + // 변수들 생성 + if (data.variables && data.variables.length > 0) { + const variableData = data.variables.map((variable, index) => ({ + templateId: newTemplate.id, + variableName: variable.variableName, + variableType: variable.variableType, + defaultValue: variable.defaultValue, + isRequired: variable.isRequired || false, + description: variable.description, + displayOrder: index, + })); + + await db.insert(templateVariables).values(variableData); + } + + // 생성된 템플릿 조회 및 반환 + const result = await getTemplate(data.slug); + if (!result) { + throw new Error('생성된 템플릿을 조회할 수 없습니다.'); + } + + return result; + } catch (error) { + console.error('Error creating template:', error); + throw error; + } +} + +/** + * 템플릿 업데이트 + */ +export async function updateTemplate(slug: string, data: { + name?: string; + subject?: string; + content?: string; + description?: string; + sampleData?: Record<string, any>; + updatedBy: number; +}): Promise<TemplateWithVariables> { + try { + const existingTemplate = await getTemplate(slug); + if (!existingTemplate) { + throw new Error('템플릿을 찾을 수 없습니다.'); + } + + // 버전 히스토리 저장 + await db.insert(templateHistory).values({ + templateId: existingTemplate.id, + version: existingTemplate.version, + subject: existingTemplate.subject, + content: existingTemplate.content, + changeDescription: '템플릿 업데이트', + changedBy: data.updatedBy, + }); + + // 템플릿 업데이트 + await db + .update(templates) + .set({ + name: data.name || existingTemplate.name, + content: data.content || existingTemplate.content, + subject: data.subject || existingTemplate.subject, + description: data.description || existingTemplate.description, + sampleData: data.sampleData || existingTemplate.sampleData, + version: existingTemplate.version + 1, + updatedAt: new Date(), + }) + .where(eq(templates.id, existingTemplate.id)); + + const result = await getTemplate(slug); + if (!result) { + throw new Error('업데이트된 템플릿을 조회할 수 없습니다.'); + } + + return result; + } catch (error) { + console.error('Error updating template:', error); + throw error; + } +} + +/** + * 템플릿 변수 추가 + */ +export async function addTemplateVariable(slug: string, variable: { + variableName: string; + variableType: string; + defaultValue?: string; + isRequired?: boolean; + description?: string; +}): Promise<TemplateVariable> { + try { + const template = await getTemplate(slug); + if (!template) { + throw new Error('템플릿을 찾을 수 없습니다.'); + } + + // 변수명 중복 확인 + const existingVariable = template.variables.find(v => v.variableName === variable.variableName); + if (existingVariable) { + throw new Error('이미 존재하는 변수명입니다.'); + } + + // 새 변수 추가 + const [newVariable] = await db + .insert(templateVariables) + .values({ + templateId: template.id, + variableName: variable.variableName, + variableType: variable.variableType, + defaultValue: variable.defaultValue, + isRequired: variable.isRequired || false, + description: variable.description, + displayOrder: template.variables.length, + }) + .returning(); + + return newVariable; + } catch (error) { + console.error('Error adding template variable:', error); + throw error; + } +} + +/** + * 템플릿 삭제 (소프트 삭제) + */ +export async function deleteTemplate(id: string): Promise<{ success: boolean; error?: string }> { + try { + await db + .update(templates) + .set({ + isActive: false, + updatedAt: new Date() + }) + .where(eq(templates.id, id)); + + return { success: true }; + } catch (error) { + console.error('Error deleting template:', error); + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 삭제에 실패했습니다.' + }; + } +} + +/** + * 템플릿 일괄 삭제 + */ +export async function bulkDeleteTemplates(ids: string[]): Promise<{ success: boolean; error?: string }> { + try { + await db + .update(templates) + .set({ + isActive: false, + updatedAt: new Date() + }) + .where(sql`${templates.id} = ANY(${ids})`); + + return { success: true }; + } catch (error) { + console.error('Error bulk deleting templates:', error); + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 일괄 삭제에 실패했습니다.' + }; + } +} + +/** + * 템플릿 복제 + */ +export async function duplicateTemplate( + id: string, + newName: string, + newSlug: string, + userId: string +): Promise<{ success: boolean; error?: string; data?: any }> { + try { + // 원본 템플릿 조회 (변수 포함) + const [originalTemplate] = await db + .select() + .from(templates) + .where(eq(templates.id, id)) + .limit(1); + + if (!originalTemplate) { + return { success: false, error: '원본 템플릿을 찾을 수 없습니다.' }; + } + + // 원본 템플릿의 변수들 조회 + const originalVariables = await db + .select() + .from(templateVariables) + .where(eq(templateVariables.templateId, id)) + .orderBy(templateVariables.displayOrder); + + // slug 중복 확인 + const [existingTemplate] = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.slug, newSlug)) + .limit(1); + + if (existingTemplate) { + return { success: false, error: '이미 존재하는 slug입니다.' }; + } + + // 새 템플릿 생성 + const [newTemplate] = await db + .insert(templates) + .values({ + name: newName, + slug: newSlug, + content: originalTemplate.content, + description: originalTemplate.description ? `${originalTemplate.description} (복사본)` : undefined, + category: originalTemplate.category, + sampleData: originalTemplate.sampleData, + createdBy: userId, + version: 1 + }) + .returning(); + + // 변수들 복제 + if (originalVariables.length > 0) { + const variableData = originalVariables.map((variable) => ({ + templateId: newTemplate.id, + variableName: variable.variableName, + variableType: variable.variableType, + defaultValue: variable.defaultValue, + isRequired: variable.isRequired, + description: variable.description, + validationRule: variable.validationRule, + displayOrder: variable.displayOrder, + })); + + await db.insert(templateVariables).values(variableData); + } + + return { + success: true, + data: { id: newTemplate.id, slug: newTemplate.slug } + }; + } catch (error) { + console.error('Error duplicating template:', error); + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 복제에 실패했습니다.' + }; + } +} + +/** + * 템플릿 미리보기 + */ +export async function previewTemplate(slug: string, data?: Record<string, unknown>, customContent?: string): Promise<string> { + try { + await ensureInitialized(); + + const template = await getTemplate(slug); + if (!template) { + throw new Error('템플릿을 찾을 수 없습니다.'); + } + + const content = customContent || template.content; + const allowedVariables = template.variables.map(v => v.variableName); + + // 보안 검증 + const validation = validateTemplateContent(content, allowedVariables); + if (!validation.isValid) { + throw new Error(`보안 검증 실패: ${validation.errors.join(', ')}`); + } + + // 템플릿 전처리 + const processedContent = preprocessTemplate(content); + + // 안전한 컨텍스트 생성 + const contextData = data || template.sampleData || {}; + const safeContext = createSafeContext(contextData, allowedVariables); + + // 템플릿 컴파일 및 렌더링 + const compiledTemplate = handlebars.compile(processedContent, { + noEscape: false, + strict: true, + }); + + return compiledTemplate(safeContext); + } catch (error) { + console.error('Error previewing template:', error); + throw error; + } +} + +// =========================================== +// Server Actions +// =========================================== + +export async function getTemplatesAction(input: GetEmailTemplateSchema) { + try { + const result = await getTemplateList(input); + return { + success: true, + data: result.data, + pageCount: result.pageCount + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 목록을 가져오는데 실패했습니다.', + data: [], + pageCount: 0 + }; + } +} + +export async function getTemplateAction(slug: string) { + try { + const template = await getTemplate(slug); + + if (!template) { + return { + success: false, + error: '템플릿을 찾을 수 없습니다.' + }; + } + + return { + success: true, + data: template + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '템플릿을 가져오는데 실패했습니다.' + }; + } +} + +export async function createTemplateAction(data: { + name: string; + slug: string; + subject: string; + content: string; + category?: string; + description?: string; + sampleData?: Record<string, any>; + createdBy: number; + variables?: Array<{ + variableName: string; + variableType: string; + defaultValue?: string; + isRequired?: boolean; + description?: string; + }>; +}) { + try { + // 보안 검증 + const allowedVariables = data.variables?.map(v => v.variableName) || []; + const validation = validateTemplateContent(data.content, allowedVariables); + + if (!validation.isValid) { + return { + success: false, + error: `보안 검증 실패: ${validation.errors.join(', ')}` + }; + } + + // 전처리 + const processedContent = preprocessTemplate(data.content); + + const template = await createTemplate({ + ...data, + content: processedContent + }); + + return { + success: true, + data: { slug: template.slug } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 생성에 실패했습니다.' + }; + } +} + +export async function updateTemplateAction(slug: string, data: { + name?: string; + subject?: string; + content?: string; + description?: string; + sampleData?: Record<string, any>; + updatedBy: string; +}) { + try { + if (data.content) { + // 기존 템플릿의 허용 변수 조회 + const existingTemplate = await getTemplate(slug); + const allowedVariables = existingTemplate?.variables.map(v => v.variableName) || []; + + // 보안 검증 + const validation = validateTemplateContent(data.content, allowedVariables); + if (!validation.isValid) { + return { + success: false, + error: `보안 검증 실패: ${validation.errors.join(', ')}` + }; + } + + // 전처리 + data.content = preprocessTemplate(data.content); + } + + const template = await updateTemplate(slug, data); + + return { + success: true, + data: template + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.' + }; + } +} + +export async function addTemplateVariableAction(slug: string, variable: { + variableName: string; + variableType: string; + defaultValue?: string; + isRequired?: boolean; + description?: string; +}) { + try { + const newVariable = await addTemplateVariable(slug, variable); + + return { + success: true, + data: newVariable + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '변수 추가에 실패했습니다.' + }; + } +} + +export async function previewTemplateAction( + slug: string, + data: Record<string, any>, + customContent?: string, + // subject: string +) { + try { + const html = await previewTemplate(slug, data, customContent); + + return { + success: true, + data: { html } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '미리보기 생성에 실패했습니다.' + }; + } +} + +export async function getTemplateSchemaAction(slug: string) { + try { + const template = await getTemplate(slug); + + if (!template) { + return { + success: false, + error: '템플릿을 찾을 수 없습니다.' + }; + } + + const schema = { + allowedVariables: template.variables.map(v => v.variableName), + allowedHelpers: ALLOWED_HELPERS, + templateType: template.category || 'general', + sampleData: template.sampleData || {}, + variables: template.variables + }; + + return { + success: true, + data: schema + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '스키마 조회에 실패했습니다.' + }; + } +}
\ No newline at end of file |
