ERP-node/backend-node/src/services/mailTemplateFileService.ts

383 lines
12 KiB
TypeScript
Raw Normal View History

2025-10-13 16:18:54 +09:00
import fs from "fs/promises";
import path from "path";
2025-10-01 17:01:31 +09:00
// MailComponent 인터페이스 정의
export interface MailComponent {
id: string;
2025-11-28 11:34:48 +09:00
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
2025-10-01 17:01:31 +09:00
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
styles?: Record<string, string>;
2025-11-28 11:34:48 +09:00
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: "info" | "warning" | "danger" | "success";
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
2025-10-01 17:01:31 +09:00
}
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
export interface QueryConfig {
id: string;
name: string;
sql: string;
parameters: any[];
}
2025-10-01 16:15:53 +09:00
export interface MailTemplate {
id: string;
name: string;
subject: string;
components: MailComponent[];
queryConfig?: {
queries: QueryConfig[];
};
recipientConfig?: {
2025-10-13 16:18:54 +09:00
type: "query" | "manual";
2025-10-01 16:15:53 +09:00
emailField?: string;
nameField?: string;
queryId?: string;
manualList?: Array<{ email: string; name?: string }>;
};
category?: string;
createdAt: string;
updatedAt: string;
}
class MailTemplateFileService {
private templatesDir: string;
constructor() {
2025-10-13 16:18:54 +09:00
// 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트
this.templatesDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-templates"
: path.join(process.cwd(), "uploads", "mail-templates");
2025-10-22 16:06:04 +09:00
// 동기적으로 디렉토리 생성
this.ensureDirectoryExistsSync();
2025-10-01 16:15:53 +09:00
}
/**
2025-10-22 16:06:04 +09:00
* 릿 ()
*/
private ensureDirectoryExistsSync() {
try {
const fsSync = require('fs');
fsSync.accessSync(this.templatesDir);
} catch {
const fsSync = require('fs');
fsSync.mkdirSync(this.templatesDir, { recursive: true, mode: 0o755 });
}
}
/**
* 릿 ()
2025-10-01 16:15:53 +09:00
*/
private async ensureDirectoryExists() {
try {
await fs.access(this.templatesDir);
} catch {
2025-10-16 10:33:21 +09:00
await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 });
2025-10-01 16:15:53 +09:00
}
}
/**
* 릿
*/
private getTemplatePath(id: string): string {
return path.join(this.templatesDir, `${id}.json`);
}
/**
* 릿
*/
async getAllTemplates(): Promise<MailTemplate[]> {
try {
const files = await fs.readdir(this.templatesDir);
2025-10-13 16:18:54 +09:00
const jsonFiles = files.filter((f) => f.endsWith(".json"));
2025-10-01 16:15:53 +09:00
const templates = await Promise.all(
jsonFiles.map(async (file) => {
const content = await fs.readFile(
path.join(this.templatesDir, file),
2025-10-13 16:18:54 +09:00
"utf-8"
2025-10-01 16:15:53 +09:00
);
return JSON.parse(content) as MailTemplate;
})
);
// 최신순 정렬
2025-10-13 16:18:54 +09:00
return templates.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
2025-10-01 16:15:53 +09:00
);
} catch (error) {
2025-10-22 16:06:04 +09:00
// 디렉토리가 없거나 읽기 실패 시 빈 배열 반환
2025-10-01 16:15:53 +09:00
return [];
}
}
/**
* 릿
*/
async getTemplateById(id: string): Promise<MailTemplate | null> {
try {
2025-10-13 16:18:54 +09:00
const content = await fs.readFile(this.getTemplatePath(id), "utf-8");
2025-10-01 16:15:53 +09:00
return JSON.parse(content);
} catch {
return null;
}
}
/**
* 릿
*/
async createTemplate(
2025-10-13 16:18:54 +09:00
data: Omit<MailTemplate, "id" | "createdAt" | "updatedAt">
2025-10-01 16:15:53 +09:00
): Promise<MailTemplate> {
const id = `template-${Date.now()}`;
const now = new Date().toISOString();
const template: MailTemplate = {
...data,
id,
createdAt: now,
updatedAt: now,
};
await fs.writeFile(
this.getTemplatePath(id),
JSON.stringify(template, null, 2),
2025-10-13 16:18:54 +09:00
"utf-8"
2025-10-01 16:15:53 +09:00
);
return template;
}
/**
* 릿
*/
async updateTemplate(
id: string,
2025-10-13 16:18:54 +09:00
data: Partial<Omit<MailTemplate, "id" | "createdAt">>
2025-10-01 16:15:53 +09:00
): Promise<MailTemplate | null> {
2025-10-13 15:17:34 +09:00
try {
const existing = await this.getTemplateById(id);
if (!existing) {
// console.error(`❌ 템플릿을 찾을 수 없음: ${id}`);
return null;
}
const updated: MailTemplate = {
...existing,
...data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
2025-10-22 17:07:38 +09:00
// // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
2025-10-13 15:17:34 +09:00
await fs.writeFile(
this.getTemplatePath(id),
JSON.stringify(updated, null, 2),
2025-10-13 16:18:54 +09:00
"utf-8"
2025-10-13 15:17:34 +09:00
);
2025-10-01 16:15:53 +09:00
2025-10-22 17:07:38 +09:00
// // console.log(`✅ 템플릿 저장 성공: ${id}`);
2025-10-13 15:17:34 +09:00
return updated;
} catch (error) {
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
throw error; // 에러를 컨트롤러로 전달
}
2025-10-01 16:15:53 +09:00
}
/**
* 릿
*/
async deleteTemplate(id: string): Promise<boolean> {
try {
await fs.unlink(this.getTemplatePath(id));
return true;
} catch {
return false;
}
}
/**
* 릿 HTML로
*/
renderTemplateToHtml(components: MailComponent[]): string {
2025-10-13 16:18:54 +09:00
let html =
'<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
2025-10-01 16:15:53 +09:00
2025-10-13 16:18:54 +09:00
components.forEach((comp) => {
2025-10-01 16:15:53 +09:00
const styles = Object.entries(comp.styles || {})
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
2025-10-13 16:18:54 +09:00
.join("; ");
2025-10-01 16:15:53 +09:00
switch (comp.type) {
2025-10-13 16:18:54 +09:00
case "text":
html += `<div style="${styles}">${comp.content || ""}</div>`;
2025-10-01 16:15:53 +09:00
break;
2025-10-13 16:18:54 +09:00
case "button":
2025-10-01 16:15:53 +09:00
html += `<div style="text-align: center; ${styles}">
2025-10-13 16:18:54 +09:00
<a href="${comp.url || "#"}"
2025-10-01 16:15:53 +09:00
style="display: inline-block; padding: 12px 24px; text-decoration: none;
2025-10-13 16:18:54 +09:00
background-color: ${comp.styles?.backgroundColor || "#007bff"};
color: ${comp.styles?.color || "#fff"};
2025-10-01 16:15:53 +09:00
border-radius: 4px;">
2025-10-13 16:18:54 +09:00
${comp.text || "Button"}
2025-10-01 16:15:53 +09:00
</a>
</div>`;
break;
2025-10-13 16:18:54 +09:00
case "image":
2025-10-01 16:15:53 +09:00
html += `<div style="${styles}">
2025-10-13 16:18:54 +09:00
<img src="${comp.src || ""}" alt="" style="max-width: 100%; height: auto;" />
2025-10-01 16:15:53 +09:00
</div>`;
break;
2025-10-13 16:18:54 +09:00
case "spacer":
2025-10-01 16:15:53 +09:00
html += `<div style="height: ${comp.height || 20}px;"></div>`;
break;
2025-11-28 11:34:48 +09:00
case "header":
html += `
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${comp.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case "infoTable":
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(comp.rows || []).map((row, i) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case "alertBox":
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[comp.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
<div>${comp.content || ''}</div>
</div>
`;
break;
case "divider":
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case "footer":
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
${(comp.ceoName || comp.businessNumber) ? `
<div style="margin-bottom: 4px;">
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
</div>
` : ''}
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
${(comp.phone || comp.email) ? `
<div style="margin-bottom: 4px;">
${comp.phone ? `Tel: ${comp.phone}` : ''}
${comp.phone && comp.email ? ' | ' : ''}
${comp.email ? `Email: ${comp.email}` : ''}
</div>
` : ''}
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
</div>
`;
break;
case "numberedList":
html += `
<div style="padding: 16px; ${styles}">
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
2025-10-01 16:15:53 +09:00
}
});
2025-10-13 16:18:54 +09:00
html += "</div>";
2025-10-01 16:15:53 +09:00
return html;
}
/**
* camelCase를 kebab-case로
*/
private camelToKebab(str: string): string {
2025-10-13 16:18:54 +09:00
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
2025-10-01 16:15:53 +09:00
}
/**
* 릿
*/
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates();
2025-10-13 16:18:54 +09:00
return allTemplates.filter((t) => t.category === category);
2025-10-01 16:15:53 +09:00
}
/**
* 릿
*/
async searchTemplates(keyword: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates();
const lowerKeyword = keyword.toLowerCase();
2025-10-13 16:18:54 +09:00
return allTemplates.filter(
(t) =>
t.name.toLowerCase().includes(lowerKeyword) ||
t.subject.toLowerCase().includes(lowerKeyword) ||
t.category?.toLowerCase().includes(lowerKeyword)
2025-10-01 16:15:53 +09:00
);
}
}
export const mailTemplateFileService = new MailTemplateFileService();