summaryrefslogtreecommitdiff
path: root/lib/email-template/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
commit9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch)
treef936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/service.ts
parente275618ff8a1ce6977d3e2567d943edb941897f9 (diff)
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/service.ts')
-rw-r--r--lib/email-template/service.ts899
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