diff options
Diffstat (limited to 'lib')
21 files changed, 5256 insertions, 414 deletions
diff --git a/lib/email-template/editor/template-content-editor.tsx b/lib/email-template/editor/template-content-editor.tsx new file mode 100644 index 00000000..4d31753c --- /dev/null +++ b/lib/email-template/editor/template-content-editor.tsx @@ -0,0 +1,609 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { AlertTriangle, Save, Mail, RefreshCw, Settings2, Code2, Lightbulb, Moon, Sun } from "lucide-react" +import { toast } from "sonner" +import { type TemplateWithVariables } from "@/db/schema" +import { previewTemplateAction, updateTemplateAction } from "../service" +import { useSession } from "next-auth/react" +import { HtmlCodeEditor } from "@/components/template-editor/html-code-editor" +import { formatHtml } from "@/lib/utils" + +interface TemplateContentEditorProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +export function TemplateContentEditor({ template, onUpdate }: TemplateContentEditorProps) { + const [content, setContent] = React.useState(() => { + return formatHtml(template.content) + }) + const [subject, setSubject] = React.useState(template.subject || '') + const [isLoading, setIsLoading] = React.useState(false) + const [isPreviewLoading, setIsPreviewLoading] = React.useState(false) + const [previewHtml, setPreviewHtml] = React.useState<string>("") + const [previewSubject, setPreviewSubject] = React.useState<string>("") + const [validationErrors, setValidationErrors] = React.useState<string[]>([]) + const [sampleData, setSampleData] = React.useState(template.sampleData || {}) + const [autoPreview, setAutoPreview] = React.useState(true) + const [darkMode, setDarkMode] = React.useState(false) // 다크모드 상태 추가 + const { data: session } = useSession(); + + React.useEffect(() => { + if (template.content !== content) { + const formatted = formatHtml(template.content) + setContent(formatted) + } + }, [template.content]) + + // HtmlCodeEditor ref + const editorRef = React.useRef<{ + focus: () => void + insertText: (text: string) => void + getEditor: () => any + }>(null) + + React.useEffect(() => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다"); + } + }, [session]); + + // 자동 미리보기 (디바운스) - 시간 늘림 + React.useEffect(() => { + if (!autoPreview) return + + const timer = setTimeout(() => { + handlePreview(true) + }, 1500) // 1.5초로 증가 + + return () => clearTimeout(timer) + }, [content, subject, sampleData, autoPreview]) + + // 실시간 검증 + React.useEffect(() => { + validateContent(content, subject) + }, [content, subject]) + + // 변수 사용량 분석 + const analyzeVariableUsage = React.useCallback(() => { + const templateVars = template.variables.map(v => v.variableName) + const usedVars = new Set<string>() + + const combinedText = content + ' ' + subject + const matches = combinedText.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g) + if (matches) { + matches.forEach(match => { + const varName = match.replace(/\{\{\s*|\s*\}\}/g, '') + usedVars.add(varName) + }) + } + + const unusedVars = templateVars.filter(v => !usedVars.has(v)) + const undefinedVars = Array.from(usedVars).filter(v => + !templateVars.includes(v) && + !['t', 'siteName', 'companyName', 'i18n'].includes(v) + ) + + return { unusedVars, undefinedVars, usedVars: Array.from(usedVars) } + }, [content, subject, template.variables]) + + // 검증 함수 + const validateContent = (content: string, subject: string) => { + const errors: string[] = [] + + if (subject.length > 200) { + errors.push('이메일 제목이 너무 깁니다 (200자 초과)') + } + + const forbiddenChars = ['\n', '\r', '\t'] + forbiddenChars.forEach(char => { + if (subject.includes(char)) { + errors.push('이메일 제목에 개행 문자나 탭 문자를 사용할 수 없습니다') + } + }) + + // HTML 기본 검증 추가 + if (content.trim()) { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(`<div>${content}</div>`, 'text/html') + + const parseError = doc.querySelector('parsererror') + if (parseError) { + errors.push('유효하지 않은 HTML 구조가 감지되었습니다.') + } + } catch (error) { + errors.push('HTML 구문 분석 중 오류가 발생했습니다.') + } + } + + const dangerousPatterns = [ + /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, + /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, + ] + + const combinedText = content + ' ' + subject + for (const pattern of dangerousPatterns) { + if (pattern.test(combinedText)) { + errors.push('보안상 위험한 구문이 감지되었습니다.') + break + } + } + + setValidationErrors(errors) + } + + // 저장 함수 + const handleSave = async () => { + if (validationErrors.length > 0) { + toast.error('검증 오류를 먼저 해결해주세요.') + return + } + + setIsLoading(true) + try { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const result = await updateTemplateAction(template.slug, { + subject, + content, + sampleData, + updatedBy: Number(session.user.id) + }) + + if (result.success) { + toast.success('템플릿이 저장되었습니다.') + onUpdate({ + ...template, + subject, + content, + sampleData, + version: template.version + 1 + }) + } else { + toast.error(result.error || '저장에 실패했습니다.') + } + } catch (error) { + toast.error('저장 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 미리보기 생성 + const handlePreview = async (silent = false) => { + if (!silent) setIsPreviewLoading(true) + + try { + const result = await previewTemplateAction( + template.slug, + sampleData, + content, + // subject // 주석 해제 + ) + + if (result.success) { + setPreviewHtml(result.data.html) + setPreviewSubject(result.data.subject) + if (!silent) toast.success('미리보기가 생성되었습니다.') + } else { + if (!silent) toast.error(result.error || '미리보기 생성에 실패했습니다.') + } + } catch (error) { + if (!silent) toast.error('미리보기 생성 중 오류가 발생했습니다.') + } finally { + if (!silent) setIsPreviewLoading(false) + } + } + + // 변수 삽입 함수 - HtmlCodeEditor 지원 + const insertVariable = (variableName: string, targetField: 'subject' | 'content') => { + const variable = `{{${variableName}}}` + + if (targetField === 'subject') { + const input = document.querySelector('input[data-subject-input]') as HTMLInputElement + if (input) { + const start = input.selectionStart || 0 + const end = input.selectionEnd || 0 + const newValue = subject.substring(0, start) + variable + subject.substring(end) + setSubject(newValue) + + setTimeout(() => { + input.focus() + input.setSelectionRange(start + variable.length, start + variable.length) + }, 0) + } + } else { + // HtmlCodeEditor에 변수 삽입 + if (editorRef.current) { + editorRef.current.insertText(variable) + } + } + } + + // HTML 템플릿 스니펫 삽입 - HtmlCodeEditor 지원 + const insertHtmlSnippet = (snippet: string) => { + if (editorRef.current) { + editorRef.current.insertText(snippet) + } + } + + // 샘플 데이터 업데이트 + const updateSampleData = (key: string, value: any) => { + const newSampleData = { ...sampleData, [key]: value } + setSampleData(newSampleData) + } + + const variableAnalysis = analyzeVariableUsage() + + // HTML 스니펫들 - 개행 추가로 더 깔끔하게 + const htmlSnippets = [ + { + name: '버튼', + snippet: `\n<div style="text-align: center; margin: 20px 0;"> + <a href="{{buttonUrl}}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;"> + {{buttonText}} + </a> +</div>\n` + }, + { + name: '구분선', + snippet: `\n<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">\n` + }, + { + name: '박스', + snippet: `\n<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin: 20px 0;"> + {{content}} +</div>\n` + }, + { + name: '이미지', + snippet: `\n<div style="text-align: center; margin: 20px 0;"> + <img src="{{imageUrl}}" alt="{{imageAlt}}" style="max-width: 100%; height: auto;"> +</div>\n` + }, + { + name: '2열 레이아웃', + snippet: `\n<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;"> + <tr> + <td width="50%" style="padding: 10px;"> + {{leftContent}} + </td> + <td width="50%" style="padding: 10px;"> + {{rightContent}} + </td> + </tr> +</table>\n` + }, + { + name: '헤더', + snippet: `\n<h2 style="color: #333; margin: 20px 0 10px 0; font-size: 24px;">{{title}}</h2>\n` + } + ] + + return ( + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* 왼쪽: 편집 영역 */} + <div className="space-y-6"> + {/* 상태 표시 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + {validationErrors.length > 0 && ( + <Badge variant="destructive" className="gap-1"> + <AlertTriangle className="h-3 w-3" /> + {validationErrors.length}개 오류 + </Badge> + )} + + {variableAnalysis.undefinedVars.length > 0 && ( + <Badge variant="secondary" className="gap-1"> + <AlertTriangle className="h-3 w-3" /> + {variableAnalysis.undefinedVars.length}개 미정의 변수 + </Badge> + )} + + <Badge variant="outline"> + {variableAnalysis.usedVars.length}/{template.variables.length} 변수 사용 중 + </Badge> + </div> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setDarkMode(!darkMode)} + title="다크모드 전환" + > + {darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => setAutoPreview(!autoPreview)} + className={autoPreview ? 'bg-blue-50' : ''} + > + <RefreshCw className={`mr-2 h-4 w-4 ${autoPreview ? 'text-blue-600' : ''}`} /> + 자동 미리보기 + </Button> + + <Button + size="sm" + onClick={handleSave} + disabled={isLoading || validationErrors.length > 0} + > + <Save className="mr-2 h-4 w-4" /> + {isLoading ? '저장 중...' : '저장'} + </Button> + </div> + </div> + + {/* 오류 표시 */} + {validationErrors.length > 0 && ( + <div className="bg-red-50 border border-red-200 rounded-lg p-4"> + <div className="flex items-start gap-3"> + <AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" /> + <div> + <h3 className="font-semibold text-red-800">검증 오류</h3> + <ul className="mt-2 text-sm text-red-700 space-y-1"> + {validationErrors.map((error, index) => ( + <li key={index}>• {error}</li> + ))} + </ul> + </div> + </div> + </div> + )} + + {/* 이메일 제목 편집 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Mail className="h-5 w-5" /> + 이메일 제목 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="subject">제목 템플릿</Label> + <Input + id="subject" + data-subject-input + value={subject} + onChange={(e) => setSubject(e.target.value)} + placeholder="예: {{userName}}님의 {{type}} 알림" + className="font-mono" + /> + </div> + + <div> + <Label className="text-sm font-medium">제목에 변수 삽입</Label> + <div className="flex flex-wrap gap-1 mt-2"> + {template.variables.map(variable => ( + <Badge + key={variable.id} + variant="outline" + className="cursor-pointer hover:bg-blue-50" + onClick={() => insertVariable(variable.variableName, 'subject')} + > + {variable.variableName} + </Badge> + ))} + </div> + </div> + </CardContent> + </Card> + + {/* 변수 분석 결과 */} + {(variableAnalysis.undefinedVars.length > 0 || variableAnalysis.unusedVars.length > 0) && ( + <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> + <h3 className="font-semibold text-blue-800 mb-3">변수 사용 분석</h3> + + {variableAnalysis.undefinedVars.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-blue-700 mb-1">미정의 변수</h4> + <div className="flex flex-wrap gap-1"> + {variableAnalysis.undefinedVars.map(varName => ( + <Badge key={varName} variant="destructive" className="text-xs"> + {varName} + </Badge> + ))} + </div> + </div> + )} + + {variableAnalysis.unusedVars.length > 0 && ( + <div> + <h4 className="text-sm font-medium text-blue-700 mb-1">미사용 변수</h4> + <div className="flex flex-wrap gap-1"> + {variableAnalysis.unusedVars.map(varName => ( + <Badge key={varName} variant="secondary" className="text-xs"> + {varName} + </Badge> + ))} + </div> + </div> + )} + </div> + )} + + {/* HTML 스니펫 도구 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Code2 className="h-5 w-5" /> + 빠른 HTML 삽입 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-2"> + {htmlSnippets.map((snippet, index) => ( + <Button + key={index} + variant="outline" + size="sm" + onClick={() => insertHtmlSnippet(snippet.snippet)} + className="justify-start" + > + {snippet.name} + </Button> + ))} + </div> + </CardContent> + </Card> + + {/* HtmlCodeEditor로 교체된 HTML 편집기 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Code2 className="h-5 w-5" /> + HTML 템플릿 편집 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* HtmlCodeEditor 사용 */} + <HtmlCodeEditor + ref={editorRef} + value={content} + onChange={setContent} + height="600px" + darkMode={darkMode} + placeholder="HTML 템플릿을 입력하세요..." + /> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 변수 삽입 */} + <div className="bg-gray-50 p-4 rounded-lg"> + <h4 className="font-medium text-gray-900 mb-2">변수 삽입</h4> + <div className="flex flex-wrap gap-1"> + {template.variables.map(variable => ( + <Badge + key={variable.id} + variant="outline" + className="cursor-pointer hover:bg-blue-50" + onClick={() => insertVariable(variable.variableName, 'content')} + > + {variable.variableName} + </Badge> + ))} + </div> + </div> + + {/* 도움말 */} + <div className="bg-amber-50 p-4 rounded-lg"> + <h4 className="font-medium text-amber-900 mb-2 flex items-center gap-1"> + <Lightbulb className="h-4 w-4" /> + 이메일 HTML 팁 + </h4> + <ul className="text-sm text-amber-800 space-y-1"> + <li>• 테이블 기반 레이아웃 사용</li> + <li>• 인라인 CSS 스타일 권장</li> + <li>• max-width: 600px 권장</li> + <li>• 이미지에 alt 속성 필수</li> + </ul> + </div> + </div> + </CardContent> + </Card> + </div> + + {/* 오른쪽: 미리보기 영역 */} + <div className="space-y-6"> + {/* 샘플 데이터 편집 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Settings2 className="h-5 w-5" /> + 샘플 데이터 + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {template.variables.length > 0 ? ( + <> + {template.variables.map(variable => ( + <div key={variable.id}> + <Label className="text-sm">{variable.variableName}</Label> + <Input + value={sampleData[variable.variableName] || ''} + onChange={(e) => updateSampleData(variable.variableName, e.target.value)} + placeholder={variable.description || `${variable.variableName} 값 입력`} + className="text-sm" + /> + </div> + ))} + + <Button + variant="outline" + size="sm" + onClick={() => handlePreview(false)} + disabled={isPreviewLoading} + className="w-full mt-4" + > + <RefreshCw className={`mr-2 h-4 w-4 ${isPreviewLoading ? 'animate-spin' : ''}`} /> + 미리보기 새로고침 + </Button> + </> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <p>변수가 없습니다.</p> + <p className="text-sm">변수 관리 탭에서 변수를 추가하세요.</p> + </div> + )} + </CardContent> + </Card> + + {/* 미리보기 결과 */} + <Card> + <CardHeader> + <CardTitle>미리보기</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 제목 미리보기 */} + {previewSubject && ( + <div className="p-3 bg-blue-50 rounded-lg"> + <Label className="text-sm font-medium text-blue-900">제목:</Label> + <p className="font-semibold text-blue-900 break-words">{previewSubject}</p> + </div> + )} + + {/* 내용 미리보기 */} + {previewHtml ? ( + <div className="border rounded-lg bg-white"> + <iframe + srcDoc={previewHtml} + sandbox="allow-same-origin" + className="w-full h-96 border-0 rounded-lg" + title="Template Preview" + /> + </div> + ) : ( + <div className="flex items-center justify-center h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300"> + <div className="text-center"> + <Mail className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">템플릿을 편집하면 미리보기가 표시됩니다</p> + <Button + variant="outline" + size="sm" + onClick={() => handlePreview(false)} + className="mt-2" + > + 미리보기 생성 + </Button> + </div> + </div> + )} + </CardContent> + </Card> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-editor.tsx b/lib/email-template/editor/template-editor.tsx new file mode 100644 index 00000000..68cade45 --- /dev/null +++ b/lib/email-template/editor/template-editor.tsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft, Save, Edit, Settings, List } from "lucide-react" +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" + +import { type TemplateWithVariables } from "@/db/schema" +import { TemplateContentEditor } from "./template-content-editor" +import { TemplateVariableManager } from "./template-variable-manager" +import { TemplateSettings } from "./template-settings" + +interface TemplateEditorProps { + templateSlug: string + initialTemplate: TemplateWithVariables +} + +export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditorProps) { + const router = useRouter() + const [template, setTemplate] = React.useState(initialTemplate) + + return ( + <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8"> + {/* 헤더 */} + <div className="flex items-center gap-4"> + <Button variant="ghost" size="icon" asChild> + <Link href="/evcp/email-template"> + <ArrowLeft className="h-4 w-4" /> + </Link> + </Button> + <div className="flex-1"> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-semibold">{template.name}</h1> + <Badge variant="outline" className="text-xs"> + v{template.version} + </Badge> + {template.category && ( + <Badge variant="secondary" className="text-xs"> + {template.category} + </Badge> + )} + </div> + <p className="text-sm text-muted-foreground"> + {template.description || "템플릿 편집"} + </p> + </div> + + {/* 헤더 액션 버튼들 */} + <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/evcp/templates/${template.slug}/send`}> + 테스트 발송 + </Link> + </Button> + </div> + </div> + + <Separator /> + + {/* 메인 편집 영역 - 3개 탭으로 간소화 */} + <Tabs defaultValue="editor" className="flex-1"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger value="editor" className="gap-2"> + <Edit className="h-4 w-4" /> + 편집 & 미리보기 + </TabsTrigger> + <TabsTrigger value="variables" className="gap-2"> + <List className="h-4 w-4" /> + 변수 관리 + </TabsTrigger> + <TabsTrigger value="settings" className="gap-2"> + <Settings className="h-4 w-4" /> + 설정 + </TabsTrigger> + </TabsList> + + {/* 편집 & 미리보기 탭 (통합) */} + <TabsContent value="editor" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>템플릿 편집 & 미리보기</CardTitle> + <CardDescription> + 왼쪽에서 이메일 제목과 내용을 편집하고, 오른쪽에서 실시간 미리보기를 확인하세요. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateContentEditor + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + + {/* 변수 관리 탭 */} + <TabsContent value="variables" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>변수 관리</CardTitle> + <CardDescription> + 템플릿에서 사용할 변수를 추가하고 관리하세요. 추가되는 변수는 실제 코드에서 정의가 되어있지 않으면 템플릿에 있더라도 나타나지 않습니다. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateVariableManager + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + + {/* 설정 탭 */} + <TabsContent value="settings" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>템플릿 설정</CardTitle> + <CardDescription> + 템플릿의 기본 정보와 설정을 관리하세요. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateSettings + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + </Tabs> + + {/* 추가 정보 */} + <div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-muted-foreground"> + <div className="bg-blue-50 p-4 rounded-lg"> + <h4 className="font-medium text-blue-900 mb-2">💡 편집 팁</h4> + <ul className="space-y-1 text-blue-800"> + <li>• 자동 미리보기를 켜두면 편집하면서 바로 확인 가능</li> + <li>• 변수 배지를 클릭하면 커서 위치에 바로 삽입</li> + <li>• Ctrl+S로 빠른 저장</li> + </ul> + </div> + + <div className="bg-green-50 p-4 rounded-lg"> + <h4 className="font-medium text-green-900 mb-2">📊 템플릿 상태</h4> + <ul className="space-y-1 text-green-800"> + <li>• 변수: {template.variables.length}개</li> + <li>• 필수 변수: {template.variables.filter(v => v.isRequired).length}개</li> + <li>• 최종 수정: {new Date(template.updatedAt).toLocaleDateString('ko-KR')}</li> + </ul> + </div> + + <div className="bg-purple-50 p-4 rounded-lg"> + <h4 className="font-medium text-purple-900 mb-2">🚀 다음 단계</h4> + <ul className="space-y-1 text-purple-800"> + <li>• 변수 관리에서 필요한 변수 추가</li> + <li>• 설정에서 카테고리 및 설명 수정</li> + <li>• 테스트 발송으로 실제 이메일 확인</li> + </ul> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-preview.tsx b/lib/email-template/editor/template-preview.tsx new file mode 100644 index 00000000..df6b8461 --- /dev/null +++ b/lib/email-template/editor/template-preview.tsx @@ -0,0 +1,406 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { + Eye, + Smartphone, + Monitor, + Tablet, + RefreshCw, + Copy, + Download, + Send +} from "lucide-react" +import { toast } from "sonner" +import { type TemplateWithVariables } from "@/db/schema" +import { previewTemplateAction } from "../service" +import { copyTextToClipboard } from "@/lib/utils" + +interface TemplatePreviewProps { + template: TemplateWithVariables +} + +type ViewMode = 'desktop' | 'tablet' | 'mobile' + +export function TemplatePreview({ template }: TemplatePreviewProps) { + const [previewData, setPreviewData] = React.useState<Record<string, any>>( + template.sampleData || {} + ) + const [previewHtml, setPreviewHtml] = React.useState<string>("") + const [isLoading, setIsLoading] = React.useState(false) + const [viewMode, setViewMode] = React.useState<ViewMode>('desktop') + const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null) + + // 미리보기 생성 + const generatePreview = async () => { + setIsLoading(true) + try { + const result = await previewTemplateAction( + template.slug, + previewData, + template.content + ) + + if (result.success) { + setPreviewHtml(result.data.html) + setLastUpdated(new Date()) + toast.success('미리보기가 생성되었습니다.') + } else { + toast.error("샘플데이터를 먼저 생성해주세요." || '미리보기 생성에 실패했습니다.') + } + } catch (error) { + toast.error('미리보기 생성 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 초기 미리보기 생성 + React.useEffect(() => { + generatePreview() + }, []) + + // 샘플 데이터 업데이트 + const updatePreviewData = (key: string, value: any) => { + setPreviewData(prev => ({ ...prev, [key]: value })) + } + + // 샘플 데이터 리셋 + const resetToDefaults = () => { + const defaultData: Record<string, any> = {} + + template.variables.forEach(variable => { + if (variable.defaultValue) { + switch (variable.variableType) { + case 'number': + defaultData[variable.variableName] = parseFloat(variable.defaultValue) || 0 + break + case 'boolean': + defaultData[variable.variableName] = variable.defaultValue.toLowerCase() === 'true' + break + default: + defaultData[variable.variableName] = variable.defaultValue + } + } else { + // 기본 샘플 값 + switch (variable.variableType) { + case 'string': + defaultData[variable.variableName] = `샘플 ${variable.variableName}` + break + case 'number': + defaultData[variable.variableName] = 123 + break + case 'boolean': + defaultData[variable.variableName] = true + break + case 'date': + defaultData[variable.variableName] = new Date().toLocaleDateString('ko-KR') + break + } + } + }) + + setPreviewData(defaultData) + toast.success('샘플 데이터가 초기화되었습니다.') + } + + // HTML 복사 + const copyHtml = async () => { + if (!previewHtml) { + toast.error("미리보기를 먼저 생성해주세요.") + return + } + + const ok = await copyTextToClipboard(previewHtml) + ok + ? toast.success("HTML이 클립보드에 복사되었습니다.") + : toast.error("복사에 실패했습니다.") + } + // HTML 다운로드 + const downloadHtml = () => { + if (!previewHtml) { + toast.error('미리보기를 먼저 생성해주세요.') + return + } + + const blob = new Blob([previewHtml], { type: 'text/html' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${template.slug}-preview.html` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + + toast.success('HTML 파일이 다운로드되었습니다.') + } + + // 뷰포트 크기 + const getViewportStyle = (): React.CSSProperties => { + switch (viewMode) { + case 'mobile': + return { width: '375px', height: '667px' } + case 'tablet': + return { width: '768px', height: '1024px' } + case 'desktop': + default: + return { width: '100%', height: '600px' } + } + } + + // 입력 컴포넌트 렌더링 + const renderInputForVariable = (variable: any) => { + const value = previewData[variable.variableName] || '' + + switch (variable.variableType) { + case 'boolean': + return ( + <Select + value={String(value)} + onValueChange={(newValue) => updatePreviewData(variable.variableName, newValue === 'true')} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="true">True</SelectItem> + <SelectItem value="false">False</SelectItem> + </SelectContent> + </Select> + ) + + case 'number': + return ( + <Input + type="number" + value={value} + onChange={(e) => updatePreviewData(variable.variableName, parseFloat(e.target.value) || 0)} + placeholder="숫자를 입력하세요" + /> + ) + + case 'date': + return ( + <Input + type="date" + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + /> + ) + + default: + // string 타입이거나 긴 텍스트인 경우 + if (variable.variableName.toLowerCase().includes('message') || + variable.variableName.toLowerCase().includes('content') || + variable.variableName.toLowerCase().includes('description')) { + return ( + <Textarea + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + placeholder="텍스트를 입력하세요" + className="min-h-[80px]" + /> + ) + } + + return ( + <Input + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + placeholder="텍스트를 입력하세요" + /> + ) + } + } + + return ( + <div className="space-y-6"> + {/* 상태 표시 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {template.variables.length}개 변수 + </Badge> + {lastUpdated && ( + <Badge variant="secondary" className="text-xs"> + 마지막 업데이트: {lastUpdated.toLocaleTimeString('ko-KR')} + </Badge> + )} + </div> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={copyHtml} + disabled={!previewHtml} + > + <Copy className="mr-2 h-4 w-4" /> + HTML 복사 + </Button> + <Button + variant="outline" + size="sm" + onClick={downloadHtml} + disabled={!previewHtml} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + + </div> + </div> + + <Tabs defaultValue="preview" className="space-y-6"> + <TabsList> + <TabsTrigger value="preview">미리보기</TabsTrigger> + <TabsTrigger value="data">샘플 데이터</TabsTrigger> + </TabsList> + + <TabsContent value="preview" className="space-y-4"> + {/* 뷰포트 모드 선택 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Label>뷰포트:</Label> + <div className="flex items-center gap-1"> + <Button + variant={viewMode === 'desktop' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('desktop')} + > + <Monitor className="h-4 w-4" /> + </Button> + <Button + variant={viewMode === 'tablet' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('tablet')} + > + <Tablet className="h-4 w-4" /> + </Button> + <Button + variant={viewMode === 'mobile' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('mobile')} + > + <Smartphone className="h-4 w-4" /> + </Button> + </div> + </div> + + <Button + onClick={generatePreview} + disabled={isLoading} + size="sm" + > + <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} /> + {isLoading ? '생성 중...' : '새로고침'} + </Button> + </div> + + {/* 미리보기 영역 */} + <div className="border rounded-lg bg-gray-100 p-4 flex justify-center"> + {previewHtml ? ( + <div + className="bg-white rounded-lg shadow-lg overflow-hidden transition-all duration-300" + style={getViewportStyle()} + > + <iframe + srcDoc={previewHtml} + sandbox="allow-same-origin" + className="w-full h-full border-0" + title="Template Preview" + /> + </div> + ) : ( + <div className="flex items-center justify-center h-[400px] text-muted-foreground"> + <div className="text-center"> + <Eye className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>미리보기를 생성하려면 새로고침 버튼을 클릭하세요.</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="data" className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">샘플 데이터 편집</h3> + <Button + variant="outline" + size="sm" + onClick={resetToDefaults} + > + 기본값으로 리셋 + </Button> + </div> + + {template.variables.length === 0 ? ( + <div className="text-center py-12 border border-dashed rounded-lg"> + <p className="text-muted-foreground">등록된 변수가 없습니다.</p> + </div> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {template.variables.map((variable) => ( + <div key={variable.id} className="space-y-2"> + <div className="flex items-center gap-2"> + <Label htmlFor={variable.variableName}> + {variable.variableName} + </Label> + <Badge variant="outline" className="text-xs"> + {variable.variableType} + </Badge> + {variable.isRequired && ( + <Badge variant="destructive" className="text-xs"> + 필수 + </Badge> + )} + </div> + + {renderInputForVariable(variable)} + + {variable.description && ( + <p className="text-xs text-muted-foreground"> + {variable.description} + </p> + )} + </div> + ))} + </div> + )} + + <div className="flex justify-end"> + <Button onClick={generatePreview} disabled={isLoading}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 업데이트 + </Button> + </div> + </TabsContent> + </Tabs> + + {/* 도움말 */} + <div className="bg-green-50 p-4 rounded-lg"> + <h4 className="font-medium text-green-900 mb-2">미리보기 가이드</h4> + <div className="text-sm text-green-800 space-y-1"> + <p>• 샘플 데이터를 수정하여 다양한 시나리오를 테스트해보세요</p> + <p>• 다양한 뷰포트에서 이메일이 어떻게 보이는지 확인하세요</p> + <p>• HTML을 복사하여 다른 이메일 클라이언트에서 테스트할 수 있습니다</p> + <p>• 변수가 올바르게 치환되는지 확인하세요</p> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-settings.tsx b/lib/email-template/editor/template-settings.tsx new file mode 100644 index 00000000..f253f87d --- /dev/null +++ b/lib/email-template/editor/template-settings.tsx @@ -0,0 +1,474 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { + Save, + Trash2, + AlertTriangle, + Info, + Calendar, + User, + Hash, + Copy +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { type TemplateWithVariables } from "@/db/schema" +import { deleteTemplate, duplicateTemplate, updateTemplateAction } from "../service" +import { TEMPLATE_CATEGORY_OPTIONS, getCategoryDisplayName } from "../validations" + + +interface TemplateSettingsProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps) { + const router = useRouter() + const [isLoading, setIsLoading] = React.useState(false) + const [formData, setFormData] = React.useState({ + name: template.name, + description: template.description || '', + category: template.category || '', + sampleData: JSON.stringify(template.sampleData || {}, null, 2) + }) + + // 폼 데이터 업데이트 + const updateFormData = (field: keyof typeof formData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + // 기본 정보 저장 + const handleSaveBasicInfo = async () => { + setIsLoading(true) + try { + // 샘플 데이터 JSON 파싱 검증 + let parsedSampleData = {} + if (formData.sampleData.trim()) { + try { + parsedSampleData = JSON.parse(formData.sampleData) + } catch (error) { + toast.error('샘플 데이터 JSON 형식이 올바르지 않습니다.') + setIsLoading(false) + return + } + } + + const result = await updateTemplateAction(template.slug, { + name: formData.name, + description: formData.description || undefined, + sampleData: parsedSampleData, + updatedBy: 'current-user-id' // TODO: 실제 사용자 ID + }) + + if (result.success) { + toast.success('템플릿 설정이 저장되었습니다.') + onUpdate({ + ...template, + name: formData.name, + description: formData.description, + sampleData: parsedSampleData, + version: template.version + 1 + }) + } else { + toast.error(result.error || '저장에 실패했습니다.') + } + } catch (error) { + toast.error('저장 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 템플릿 복제 + const handleDuplicate = async () => { + setIsLoading(true) + try { + const copyName = `${template.name} (복사본)` + const copySlug = `${template.slug}-copy-${Date.now()}` + + const result = await duplicateTemplate( + template.id, + copyName, + copySlug, + 'current-user-id' // TODO: 실제 사용자 ID + ) + + if (result.success && result.data) { + toast.success('템플릿이 복제되었습니다.') + router.push(`/evcp/templates/${result.data.slug}`) + } else { + toast.error(result.error || '복제에 실패했습니다.') + } + } catch (error) { + toast.error('복제 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 템플릿 삭제 + const handleDelete = async () => { + setIsLoading(true) + try { + const result = await deleteTemplate(template.id) + + if (result.success) { + toast.success('템플릿이 삭제되었습니다.') + router.push('/evcp/templates') + } else { + toast.error(result.error || '삭제에 실패했습니다.') + } + } catch (error) { + toast.error('삭제 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 샘플 데이터 포맷팅 + const formatSampleData = () => { + try { + const parsed = JSON.parse(formData.sampleData) + const formatted = JSON.stringify(parsed, null, 2) + updateFormData('sampleData', formatted) + toast.success('JSON이 포맷팅되었습니다.') + } catch (error) { + toast.error('유효한 JSON이 아닙니다.') + } + } + + // 기본 샘플 데이터 생성 + const generateDefaultSampleData = () => { + const defaultData: Record<string, any> = {} + + template.variables.forEach(variable => { + switch (variable.variableType) { + case 'string': + defaultData[variable.variableName] = variable.defaultValue || `샘플 ${variable.variableName}` + break + case 'number': + defaultData[variable.variableName] = variable.defaultValue ? parseFloat(variable.defaultValue) : 123 + break + case 'boolean': + defaultData[variable.variableName] = variable.defaultValue ? variable.defaultValue === 'true' : true + break + case 'date': + defaultData[variable.variableName] = variable.defaultValue || new Date().toLocaleDateString('ko-KR') + break + } + }) + + const formatted = JSON.stringify(defaultData, null, 2) + updateFormData('sampleData', formatted) + toast.success('기본 샘플 데이터가 생성되었습니다.') + } + + return ( + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + <CardDescription> + 템플릿의 기본 정보를 수정할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="name">템플릿 이름</Label> + <Input + id="name" + value={formData.name} + onChange={(e) => updateFormData('name', e.target.value)} + placeholder="템플릿 이름을 입력하세요" + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={formData.description} + onChange={(e) => updateFormData('description', e.target.value)} + placeholder="템플릿에 대한 설명을 입력하세요" + className="min-h-[100px]" + /> + </div> + + <div> + <Label htmlFor="category">카테고리</Label> + <Select + value={formData.category || "none"} // 빈 문자열일 때 "none"으로 표시 + onValueChange={(value) => { + // "none"이 선택되면 빈 문자열로 변환 + updateFormData('category', value === "none" ? "" : value) + }} + > + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">카테고리 없음</SelectItem> {/* ✅ "none" 사용 */} + {TEMPLATE_CATEGORY_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> +</div> + + <div className="flex justify-end"> + <Button onClick={handleSaveBasicInfo} disabled={isLoading}> + <Save className="mr-2 h-4 w-4" /> + {isLoading ? '저장 중...' : '기본 정보 저장'} + </Button> + </div> + </CardContent> + </Card> + + {/* 샘플 데이터 */} + <Card> + <CardHeader> + <CardTitle>샘플 데이터</CardTitle> + <CardDescription> + 미리보기에서 사용될 기본 샘플 데이터를 설정합니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={formatSampleData} + > + JSON 포맷팅 + </Button> + <Button + variant="outline" + size="sm" + onClick={generateDefaultSampleData} + > + 기본 데이터 생성 + </Button> + </div> + + <div> + <Label htmlFor="sampleData">샘플 데이터 (JSON)</Label> + <Textarea + id="sampleData" + value={formData.sampleData} + onChange={(e) => updateFormData('sampleData', e.target.value)} + placeholder='{"userName": "홍길동", "email": "user@example.com"}' + className="min-h-[200px] font-mono text-sm" + /> + </div> + + <div className="bg-blue-50 p-3 rounded-lg"> + <p className="text-sm text-blue-800"> + <Info className="inline h-4 w-4 mr-1" /> + 샘플 데이터는 템플릿 미리보기에서 기본값으로 사용됩니다. + </p> + </div> + </CardContent> + </Card> + + {/* 메타 정보 */} + <Card> + <CardHeader> + <CardTitle>메타 정보</CardTitle> + <CardDescription> + 템플릿의 상세 정보를 확인할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Hash className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">Slug:</span> + <code className="text-sm bg-muted px-2 py-1 rounded"> + {template.slug} + </code> + </div> + + <div className="flex items-center gap-2"> + <Badge variant="outline">버전 {template.version}</Badge> + <Badge variant={template.category ? "default" : "secondary"}> + {getCategoryDisplayName(template.category)} + </Badge> + </div> + + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 생성일: {new Date(template.createdAt).toLocaleString('ko-KR')} + </span> + </div> + + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 수정일: {new Date(template.updatedAt).toLocaleString('ko-KR')} + </span> + </div> + </div> + + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 생성자: {template.createdBy} + </span> + </div> + + <div> + <span className="text-sm font-medium">변수 개수:</span> + <span className="ml-2 text-sm">{template.variables.length}개</span> + </div> + + <div> + <span className="text-sm font-medium">필수 변수:</span> + <span className="ml-2 text-sm"> + {template.variables.filter(v => v.isRequired).length}개 + </span> + </div> + + <div> + <span className="text-sm font-medium">콘텐츠 길이:</span> + <span className="ml-2 text-sm">{template.content.length} 문자</span> + </div> + </div> + </div> + </CardContent> + </Card> + + <Separator /> + + {/* 위험한 작업 */} + <Card className="border-destructive"> + <CardHeader> + <CardTitle className="text-destructive">위험한 작업</CardTitle> + <CardDescription> + 다음 작업들은 신중히 수행해주세요. 일부는 되돌릴 수 없습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between p-4 border rounded-lg"> + <div> + <h4 className="font-medium">템플릿 복제</h4> + <p className="text-sm text-muted-foreground"> + 현재 템플릿을 복사하여 새로운 템플릿을 생성합니다. + </p> + </div> + <Button + variant="outline" + onClick={handleDuplicate} + disabled={isLoading} + > + <Copy className="mr-2 h-4 w-4" /> + 복제 + </Button> + </div> + + <div className="flex items-center justify-between p-4 border border-destructive rounded-lg bg-destructive/5"> + <div> + <h4 className="font-medium text-destructive">템플릿 삭제</h4> + <p className="text-sm text-muted-foreground"> + 이 템플릿을 완전히 삭제합니다. 이 작업은 되돌릴 수 없습니다. + </p> + </div> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="destructive" disabled={isLoading}> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>템플릿 삭제 확인</AlertDialogTitle> + <AlertDialogDescription> + 정말로 <strong>"{template.name}"</strong> 템플릿을 삭제하시겠습니까? + <br /> + <br /> + 이 작업은 되돌릴 수 없으며, 다음 항목들이 함께 삭제됩니다: + <br /> + • 템플릿 내용 및 설정 + <br /> + • 모든 변수 ({template.variables.length}개) + <br /> + • 변경 이력 + <br /> + <br /> + <span className="text-destructive font-medium"> + 삭제하려면 "영구 삭제"를 클릭하세요. + </span> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + <Trash2 className="mr-2 h-4 w-4" /> + 영구 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </CardContent> + </Card> + + {/* 주의사항 */} + <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg"> + <div className="flex items-start gap-3"> + <AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5" /> + <div> + <h3 className="font-semibold text-amber-800">주의사항</h3> + <div className="mt-2 text-sm text-amber-700 space-y-1"> + <p>• 템플릿 설정 변경 시 기존 미리보기가 무효화될 수 있습니다.</p> + <p>• 샘플 데이터는 유효한 JSON 형식이어야 합니다.</p> + <p>• 템플릿 삭제는 되돌릴 수 없으니 신중히 결정하세요.</p> + <p>• 카테고리 변경 시 관련 기본 변수가 영향받을 수 있습니다.</p> + </div> + </div> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-variable-manager.tsx b/lib/email-template/editor/template-variable-manager.tsx new file mode 100644 index 00000000..9b86dd5e --- /dev/null +++ b/lib/email-template/editor/template-variable-manager.tsx @@ -0,0 +1,562 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Plus, Edit, Trash2, GripVertical, Copy } from "lucide-react" +import { toast } from "sonner" +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd" +import { type TemplateWithVariables, type TemplateVariable } from "@/db/schema" +import { addTemplateVariableAction } from "../service" + +interface TemplateVariableManagerProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +interface VariableFormData { + variableName: string + variableType: 'string' | 'number' | 'boolean' | 'date' + defaultValue: string + isRequired: boolean + description: string +} + +export function TemplateVariableManager({ template, onUpdate }: TemplateVariableManagerProps) { + const [variables, setVariables] = React.useState(template.variables) + // const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [editingVariable, setEditingVariable] = React.useState<TemplateVariable | null>(null) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) // 이름 변경 + + const [formData, setFormData] = React.useState<VariableFormData>({ + variableName: '', + variableType: 'string', + defaultValue: '', + isRequired: false, + description: '' + }) + + const isEditMode = editingVariable !== null + + // 폼 초기화 + const resetForm = () => { + setFormData({ + variableName: '', + variableType: 'string', + defaultValue: '', + isRequired: false, + description: '' + }) + setEditingVariable(null) + } + + const handleEditVariable = (variable: TemplateVariable) => { + setEditingVariable(variable) + setFormData({ + variableName: variable.variableName, + variableType: variable.variableType as any, + defaultValue: variable.defaultValue || '', + isRequired: variable.isRequired || false, + description: variable.description || '' + }) + setIsDialogOpen(true) + } + + + const handleSubmitVariable = async () => { + if (!formData.variableName.trim()) { + toast.error('변수명을 입력해주세요.') + return + } + + // 편집 모드가 아닐 때만 중복 검사 + if (!isEditMode && variables.some(v => v.variableName === formData.variableName)) { + toast.error('이미 존재하는 변수명입니다.') + return + } + + // 편집 모드일 때 다른 변수와 중복되는지 검사 + if (isEditMode && variables.some(v => v.id !== editingVariable!.id && v.variableName === formData.variableName)) { + toast.error('이미 존재하는 변수명입니다.') + return + } + + // 변수명 유효성 검사 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) { + toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.') + return + } + + setIsSubmitting(true) + try { + if (isEditMode) { + // 편집 모드 - TODO: updateTemplateVariableAction 구현 필요 + // const result = await updateTemplateVariableAction(template.slug, editingVariable!.id, formData) + + // 임시로 클라이언트 사이드에서 업데이트 + const updatedVariables = variables.map(v => + v.id === editingVariable!.id + ? { ...v, ...formData } + : v + ) + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 수정되었습니다.') + } else { + // 추가 모드 + const result = await addTemplateVariableAction(template.slug, { + variableName: formData.variableName, + variableType: formData.variableType, + defaultValue: formData.defaultValue || undefined, + isRequired: formData.isRequired, + description: formData.description || undefined, + }) + + if (result.success) { + const newVariable = result.data + const updatedVariables = [...variables, newVariable] + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 추가되었습니다.') + } else { + toast.error(result.error || '변수 추가에 실패했습니다.') + return + } + } + + setIsDialogOpen(false) + resetForm() + } catch (error) { + toast.error(`변수 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다.`) + } finally { + setIsSubmitting(false) + } + } + + // Dialog 닫기 처리 + const handleDialogClose = (open: boolean) => { + setIsDialogOpen(open) + if (!open) { + resetForm() + } + } + + // 변수 추가 + // const handleAddVariable = async () => { + // if (!formData.variableName.trim()) { + // toast.error('변수명을 입력해주세요.') + // return + // } + + // // 변수명 중복 검사 + // if (variables.some(v => v.variableName === formData.variableName)) { + // toast.error('이미 존재하는 변수명입니다.') + // return + // } + + // // 변수명 유효성 검사 + // if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) { + // toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.') + // return + // } + + // setIsSubmitting(true) + // try { + // const result = await addTemplateVariableAction(template.slug, { + // variableName: formData.variableName, + // variableType: formData.variableType, + // defaultValue: formData.defaultValue || undefined, + // isRequired: formData.isRequired, + // description: formData.description || undefined, + // }) + + // if (result.success) { + // const newVariable = result.data + // const updatedVariables = [...variables, newVariable] + // setVariables(updatedVariables) + // onUpdate({ ...template, variables: updatedVariables }) + + // toast.success('변수가 추가되었습니다.') + // setIsAddDialogOpen(false) + // resetForm() + // } else { + // toast.error(result.error || '변수 추가에 실패했습니다.') + // } + // } catch (error) { + // toast.error('변수 추가 중 오류가 발생했습니다.') + // } finally { + // setIsSubmitting(false) + // } + // } + + // 변수 순서 변경 + const handleDragEnd = (result: any) => { + if (!result.destination) return + + const items = Array.from(variables) + const [reorderedItem] = items.splice(result.source.index, 1) + items.splice(result.destination.index, 0, reorderedItem) + + // displayOrder 업데이트 + const updatedItems = items.map((item, index) => ({ + ...item, + displayOrder: index + })) + + setVariables(updatedItems) + onUpdate({ ...template, variables: updatedItems }) + + // TODO: 서버에 순서 변경 요청 + toast.success('변수 순서가 변경되었습니다.') + } + + // 변수 복사 + const handleCopyVariable = (variable: TemplateVariable) => { + const copyName = `${variable.variableName}_copy` + setFormData({ + variableName: copyName, + variableType: variable.variableType as any, + defaultValue: variable.defaultValue || '', + isRequired: variable.isRequired || false, + description: variable.description || '' + }) + setIsDialogOpen(true) + } + + // 변수 삭제 + const handleDeleteVariable = async (variableId: string) => { + // TODO: 서버에서 변수 삭제 구현 + const updatedVariables = variables.filter(v => v.id !== variableId) + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 삭제되었습니다.') + } + + // 변수 타입에 따른 기본값 예시 + const getDefaultValuePlaceholder = (type: string) => { + switch (type) { + case 'string': return '예: 홍길동' + case 'number': return '예: 123' + case 'boolean': return 'true 또는 false' + case 'date': return '예: 2025-01-01' + default: return '' + } + } + + // 변수 타입별 아이콘 + const getVariableTypeIcon = (type: string) => { + switch (type) { + case 'string': return '📝' + case 'number': return '🔢' + case 'boolean': return '✅' + case 'date': return '📅' + default: return '❓' + } + } + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + {/* <h3 className="text-lg font-semibold">변수 관리</h3> */} + <p className="text-sm text-muted-foreground"> + 총 {variables.length}개의 변수가 등록되어 있습니다. + </p> + </div> + + <Dialog open={isDialogOpen} onOpenChange={handleDialogClose}> + <DialogTrigger asChild> + <Button onClick={() => { resetForm(); setIsDialogOpen(true) }}> + <Plus className="mr-2 h-4 w-4" /> + 변수 추가 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {isEditMode ? '변수 수정' : '새 변수 추가'} + </DialogTitle> + <DialogDescription> + {isEditMode + ? '기존 변수의 정보를 수정합니다.' + : '템플릿에서 사용할 새로운 변수를 추가합니다.' + } + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label htmlFor="variableName">변수명</Label> + <Input + id="variableName" + value={formData.variableName} + onChange={(e) => setFormData(prev => ({ ...prev, variableName: e.target.value }))} + placeholder="예: userName, orderDate" + /> + </div> + + <div> + <Label htmlFor="variableType">타입</Label> + <Select + value={formData.variableType} + onValueChange={(value) => setFormData(prev => ({ ...prev, variableType: value as any }))} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="string">📝 문자열</SelectItem> + <SelectItem value="number">🔢 숫자</SelectItem> + <SelectItem value="boolean">✅ 불린</SelectItem> + <SelectItem value="date">📅 날짜</SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="defaultValue">기본값</Label> + <Input + id="defaultValue" + value={formData.defaultValue} + onChange={(e) => setFormData(prev => ({ ...prev, defaultValue: e.target.value }))} + placeholder={getDefaultValuePlaceholder(formData.variableType)} + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={formData.description} + onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="변수에 대한 설명을 입력하세요" + className="min-h-[80px]" + /> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="isRequired" + checked={formData.isRequired} + onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isRequired: !!checked }))} + /> + <Label htmlFor="isRequired">필수 변수</Label> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => handleDialogClose(false)} + > + 취소 + </Button> + <Button + onClick={handleSubmitVariable} + disabled={isSubmitting} + > + {isSubmitting + ? `${isEditMode ? '수정' : '추가'} 중...` + : isEditMode ? '수정' : '추가' + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + + {/* 변수 목록 */} + {variables.length === 0 ? ( + <div className="text-center py-12 border border-dashed rounded-lg"> + <div className="text-muted-foreground"> + <Plus className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p className="text-lg font-medium">등록된 변수가 없습니다</p> + <p className="text-sm">첫 번째 변수를 추가해보세요.</p> + </div> + </div> + ) : ( + <DragDropContext onDragEnd={handleDragEnd}> + <Droppable droppableId="variables"> + {(provided) => ( + <div + {...provided.droppableProps} + ref={provided.innerRef} + className="border rounded-lg" + > + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-10"></TableHead> + <TableHead>변수명</TableHead> + <TableHead>타입</TableHead> + <TableHead>기본값</TableHead> + <TableHead>필수</TableHead> + <TableHead>설명</TableHead> + <TableHead className="w-24">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {variables.map((variable, index) => ( + <Draggable + key={variable.id} + draggableId={variable.id} + index={index} + > + {(provided, snapshot) => ( + <TableRow + ref={provided.innerRef} + {...provided.draggableProps} + className={snapshot.isDragging ? 'bg-muted' : ''} + > + <TableCell> + <div + {...provided.dragHandleProps} + className="cursor-grab hover:cursor-grabbing" + > + <GripVertical className="h-4 w-4 text-muted-foreground" /> + </div> + </TableCell> + <TableCell> + <div className="font-mono text-sm"> + {variable.variableName} + </div> + </TableCell> + <TableCell> + <Badge variant="outline" className="gap-1"> + {getVariableTypeIcon(variable.variableType)} + {variable.variableType} + </Badge> + </TableCell> + <TableCell> + <div className="text-sm text-muted-foreground max-w-[100px] truncate"> + {variable.defaultValue || '-'} + </div> + </TableCell> + <TableCell> + {variable.isRequired ? ( + <Badge variant="destructive" className="text-xs">필수</Badge> + ) : ( + <Badge variant="secondary" className="text-xs">선택</Badge> + )} + </TableCell> + <TableCell> + <div className="text-sm text-muted-foreground max-w-[200px] truncate"> + {variable.description || '-'} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleCopyVariable(variable)} + > + <Copy className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleEditVariable(variable)} + > + <Edit className="h-3 w-3" /> + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-8 w-8 text-destructive hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>변수 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 '{variable.variableName}' 변수를 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => handleDeleteVariable(variable.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </TableCell> + </TableRow> + )} + </Draggable> + ))} + {provided.placeholder} + </TableBody> + </Table> + </div> + )} + </Droppable> + </DragDropContext> + )} + + {/* 도움말 */} + <div className="bg-blue-50 p-4 rounded-lg"> + <h4 className="font-medium text-blue-900 mb-2">변수 사용법</h4> + <div className="text-sm text-blue-800 space-y-1"> + <p>• 템플릿에서 <code className="bg-blue-100 px-1 rounded">{`{{variableName}}`}</code> 형태로 사용</p> + <p>• 드래그 앤 드롭으로 변수 순서 변경 가능</p> + <p>• 필수 변수는 반드시 값이 제공되어야 함</p> + <p>• 변수명은 영문자, 숫자, 언더스코어만 사용 가능</p> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/security.ts b/lib/email-template/security.ts new file mode 100644 index 00000000..57eb4314 --- /dev/null +++ b/lib/email-template/security.ts @@ -0,0 +1,178 @@ + + +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; + } + + // 허용된 변수 목록 (환경변수나 설정파일에서 관리하는 것을 권장) + const ALLOWED_VARIABLES = [ + 'userName', 'companyName', 'email', 'date', 'projectName', + 'message', 'currentYear', 'language', 'name', 'loginUrl' + ]; + + // 허용된 Handlebars 헬퍼 + const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with']; + + // 위험한 패턴들 + const DANGEROUS_PATTERNS = [ + // JavaScript 생성자 및 프로토타입 접근 + /\{\{\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, + /\{\{\s*.*\.\.\/.*\}\}/gi, + + // 함수 호출 패턴 + /\{\{\s*.*\(\s*.*\)\s*.*\}\}/gi, + + // 특수 문자를 이용한 우회 시도 + /\{\{\s*.*[\[\](){}].*\}\}/gi, + ]; + + // 의심스러운 패턴들 (경고용) + const SUSPICIOUS_PATTERNS = [ + /\{\{\s*.*password.*\}\}/gi, + /\{\{\s*.*secret.*\}\}/gi, + /\{\{\s*.*key.*\}\}/gi, + /\{\{\s*.*token.*\}\}/gi, + ]; + + /** + * 템플릿 내용의 보안성을 검증합니다 + */ + export function validateTemplateContent(content: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // 1. 위험한 패턴 검사 + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(content)) { + errors.push('보안상 위험한 구문이 감지되었습니다.'); + break; + } + } + + // 2. 의심스러운 패턴 검사 (경고) + for (const pattern of SUSPICIOUS_PATTERNS) { + if (pattern.test(content)) { + warnings.push('민감한 정보가 포함될 수 있는 변수가 감지되었습니다.'); + break; + } + } + + // 3. 허용되지 않은 변수 검사 + 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 (!ALLOWED_VARIABLES.includes(variable)) { + errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`); + } + } + } + + // 4. 허용되지 않은 헬퍼 검사 + 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}'가 사용되었습니다.`); + } + } + } + + // 5. HTML 출력 검사 ({{{...}}} 형태) + const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g); + if (htmlOutputMatches) { + warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.'); + } + + // 6. 템플릿 길이 제한 (DOS 방지) + if (content.length > 50000) { // 50KB 제한 + errors.push('템플릿 크기가 제한을 초과했습니다.'); + } + + // 7. 중첩 깊이 제한 + const nestingDepth = (content.match(/\{\{#/g) || []).length; + if (nestingDepth > 10) { + errors.push('템플릿 중첩 깊이가 제한을 초과했습니다.'); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * HTML 출력을 일반 출력으로 변환합니다 + */ + export function sanitizeTripleBraces(content: string): string { + return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => { + return `{{${variable.trim()}}}`; + }); + } + + /** + * 템플릿을 안전하게 전처리합니다 + */ + export function preprocessTemplate(content: string): string { + // 1. HTML 출력을 일반 출력으로 변환 + let processed = sanitizeTripleBraces(content); + + // 2. 의심스러운 공백 문자 제거 + processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, ''); + + // 3. 과도한 공백 정리 + processed = processed.replace(/\s+/g, ' ').trim(); + + return processed; + } + + /** + * Handlebars 컨텍스트를 제한합니다 + */ + export function createSafeContext(data: Record<string, any>): Record<string, any> { + const safeContext: Record<string, any> = {}; + + // 허용된 변수만 컨텍스트에 포함 + for (const key of ALLOWED_VARIABLES) { + if (key in data) { + // 값도 안전하게 처리 + const value = data[key]; + if (typeof value === 'string') { + // HTML 태그 제거 (선택적) + safeContext[key] = value.replace(/<[^>]*>/g, ''); + } else if (typeof value === 'number' || typeof value === 'boolean') { + safeContext[key] = value; + } else { + // 객체나 함수는 제외 + safeContext[key] = String(value); + } + } + } + + return safeContext; + }
\ No newline at end of file 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 diff --git a/lib/email-template/table/create-template-sheet.tsx b/lib/email-template/table/create-template-sheet.tsx new file mode 100644 index 00000000..199e20ab --- /dev/null +++ b/lib/email-template/table/create-template-sheet.tsx @@ -0,0 +1,381 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { createTemplateAction } from "../service" +import { TEMPLATE_CATEGORY_OPTIONS } from "../validations" + +// Validation Schema (수정됨) +const createTemplateSchema = z.object({ + name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"), + slug: z.string() + .min(1, "Slug는 필수입니다") + .max(50, "Slug는 50자 이하여야 합니다") + .regex(/^[a-z0-9-]+$/, "Slug는 소문자, 숫자, 하이픈만 사용 가능합니다"), + description: z.string().max(500, "설명은 500자 이하여야 합니다").optional(), + category: z.string().optional(), // 빈 문자열이나 undefined 모두 허용 +}) + + + +type CreateTemplateSchema = z.infer<typeof createTemplateSchema> + +interface CreateTemplateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { +} + +export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { + const [isCreatePending, startCreateTransition] = React.useTransition() + const router = useRouter() + const { data: session } = useSession(); + + // 또는 더 안전하게 + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const form = useForm<CreateTemplateSchema>({ + resolver: zodResolver(createTemplateSchema), + defaultValues: { + name: "", + slug: "", + description: "", + category: undefined, // 기본값을 undefined로 설정 + }, + }) + + // 이름 입력 시 자동으로 slug 생성 + const watchedName = form.watch("name") + React.useEffect(() => { + if (watchedName && !form.formState.dirtyFields.slug) { + const autoSlug = watchedName + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') // 특수문자 제거 + .replace(/\s+/g, '-') // 공백을 하이픈으로 + .replace(/-+/g, '-') // 연속 하이픈 제거 + .trim() + .slice(0, 50) // 최대 50자 + + form.setValue("slug", autoSlug, { shouldValidate: false }) + } + }, [watchedName, form]) + + // 기본 템플릿 내용 생성 + const getDefaultContent = (category: string, name: string) => { + const templates = { + 'welcome-email': ` +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>환영합니다!</title> +</head> +<body> + <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> + <h1>안녕하세요, {{userName}}님!</h1> + <p>${name}에 오신 것을 환영합니다.</p> + <p>{{message}}</p> + </div> +</body> +</html>`, + 'password-reset': ` +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>비밀번호 재설정</title> +</head> +<body> + <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> + <h1>비밀번호 재설정</h1> + <p>안녕하세요, {{userName}}님.</p> + <p>비밀번호 재설정을 위해 아래 링크를 클릭해주세요.</p> + <a href="{{resetLink}}">비밀번호 재설정</a> + </div> +</body> +</html>`, + 'notification': ` +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>알림</title> +</head> +<body> + <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> + <h1>알림</h1> + <p>안녕하세요, {{userName}}님.</p> + <p>{{message}}</p> + </div> +</body> +</html>`, + } + + return templates[category as keyof typeof templates] || ` +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>${name}</title> +</head> +<body> + <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> + <h1>${name}</h1> + <p>안녕하세요, {{userName}}님.</p> + <p>{{message}}</p> + </div> +</body> +</html>` + } + + // 기본 변수 생성 + const getDefaultVariables = (category: string) => { + const variableTemplates = { + 'welcome-email': [ + { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' }, + { variableName: 'email', variableType: 'string', isRequired: true, description: '사용자 이메일' }, + { variableName: 'message', variableType: 'string', isRequired: false, description: '환영 메시지' }, + ], + 'password-reset': [ + { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' }, + { variableName: 'expiryTime', variableType: 'string', isRequired: true, description: '링크 유효 시간' }, + { variableName: 'resetLink', variableType: 'string', isRequired: true, description: '재설정 URL' }, + { variableName: 'supportEmail', variableType: 'string', isRequired: true, description: 'eVCP 서포터 이메일' }, + ], + 'notification': [ + { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' }, + { variableName: 'email', variableType: 'string', isRequired: true, description: '사용자 이메일' }, + { variableName: 'message', variableType: 'string', isRequired: true, description: '알림 메시지' }, + ], + } + + return variableTemplates[category as keyof typeof variableTemplates] || [ + { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' }, + { variableName: 'message', variableType: 'string', isRequired: false, description: '메시지 내용' }, + ] + } + + const getDefaultSubject = (category: string, name: string) => { + const subjectTemplates = { + 'welcome-email': '{{siteName}}에 오신 것을 환영합니다, {{userName}}님!', + 'password-reset': '{{userName}}님의 비밀번호 재설정 요청', + 'notification': '[{{notificationType}}] {{title}}', + 'invoice': '{{companyName}} 인보이스 #{{invoiceNumber}}', + 'marketing': '{{title}} - {{siteName}}', + 'system': '[시스템] {{title}}' + } + + return subjectTemplates[category as keyof typeof subjectTemplates] || + `${name} - {{siteName}}` + } + + + function onSubmit(input: CreateTemplateSchema) { + startCreateTransition(async () => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + + const defaultContent = getDefaultContent(input.category || '', input.name) + const defaultVariables = getDefaultVariables(input.category || '') + const defaultSubject = getDefaultSubject(input.category || '', input.name) + + const { error, data } = await createTemplateAction({ + name: input.name, + slug: input.slug, + subject: defaultSubject, + content: defaultContent, + description: input.description, + category: input.category || undefined, // 빈 문자열 대신 undefined 전달 + sampleData: { + userName: '홍길동', + email: 'user@example.com', + message: '샘플 메시지입니다.', + }, + createdBy: Number(session.user.id), + variables: defaultVariables, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("템플릿이 생성되었습니다") + + // 생성된 템플릿의 세부 페이지로 이동 + if (data?.slug) { + router.push(`/evcp/email-template/${data.slug}`) + } else { + window.location.reload() + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>새 템플릿 생성</SheetTitle> + <SheetDescription> + 새로운 이메일 템플릿을 생성합니다. 기본 구조가 자동으로 생성되며, 생성 후 세부 내용을 편집할 수 있습니다. + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>템플릿 이름</FormLabel> + <FormControl> + <Input + placeholder="예: 신규 회원 환영 메일" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="slug" + render={({ field }) => ( + <FormItem> + <FormLabel>Slug</FormLabel> + <FormControl> + <Input + placeholder="예: welcome-new-member" + {...field} + /> + </FormControl> + <FormDescription> + URL에 사용될 고유 식별자입니다. 소문자, 숫자, 하이픈만 사용 가능합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리</FormLabel> + <Select + onValueChange={(value) => { + // "none" 값이 선택되면 undefined로 설정 + field.onChange(value === "none" ? undefined : value) + }} + value={field.value || "none"} // undefined인 경우 "none"으로 표시 + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="none">카테고리 없음</SelectItem> + {TEMPLATE_CATEGORY_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + 카테고리에 따라 기본 템플릿과 변수가 자동으로 생성됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="템플릿에 대한 설명을 입력하세요" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 생성 후 편집하기 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/email-template/table/delete-template-dialog.tsx b/lib/email-template/table/delete-template-dialog.tsx new file mode 100644 index 00000000..5bc7dc53 --- /dev/null +++ b/lib/email-template/table/delete-template-dialog.tsx @@ -0,0 +1,137 @@ +// delete-template-dialog.tsx +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { type TemplateListView } from "@/db/schema" + +import { deleteTemplate, bulkDeleteTemplates } from "../service" + +interface DeleteTemplateDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + templates: TemplateListView[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteTemplateDialog({ + templates, + showTrigger = true, + onSuccess, + ...props +}: DeleteTemplateDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + + const isMultiple = templates.length > 1 + const templateName = isMultiple ? `${templates.length}개 템플릿` : templates[0]?.name + + function onDelete() { + startDeleteTransition(async () => { + let result + + if (isMultiple) { + const ids = templates.map(t => t.id) + result = await bulkDeleteTemplates(ids) + } else { + result = await deleteTemplate(templates[0].id) + } + + if (result.error) { + toast.error(result.error) + return + } + + props.onOpenChange?.(false) + onSuccess?.() + toast.success( + isMultiple + ? `${templates.length}개 템플릿이 삭제되었습니다` + : "템플릿이 삭제되었습니다" + ) + + // 페이지 새로고침으로 데이터 갱신 + window.location.reload() + }) + } + + return ( + <Dialog {...props}> + {showTrigger && ( + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="text-destructive hover:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DialogTrigger> + )} + <DialogContent> + <DialogHeader> + <DialogTitle>템플릿 삭제 확인</DialogTitle> + <DialogDescription> + 정말로 <strong>{templateName}</strong>을(를) 삭제하시겠습니까? + {!isMultiple && ( + <> + <br /> + 이 작업은 되돌릴 수 없습니다. + </> + )} + </DialogDescription> + </DialogHeader> + + {/* 삭제될 템플릿 목록 표시 */} + {templates.length > 0 && ( + <div className="rounded-lg bg-muted p-3 max-h-40 overflow-y-auto"> + <h4 className="text-sm font-medium mb-2">삭제될 템플릿</h4> + <div className="space-y-1"> + {templates.map((template) => ( + <div key={template.id} className="text-xs text-muted-foreground"> + <div className="font-medium">{template.name}</div> + <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div> + </div> + ))} + </div> + </div> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isDeletePending} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + {isMultiple ? `${templates.length}개 삭제` : "삭제"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/email-template/table/duplicate-template-sheet.tsx b/lib/email-template/table/duplicate-template-sheet.tsx new file mode 100644 index 00000000..767a8bb6 --- /dev/null +++ b/lib/email-template/table/duplicate-template-sheet.tsx @@ -0,0 +1,208 @@ +"use client" + +import * as React from "react" +import { type TemplateListView } from "@/db/schema/template-views" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { duplicateTemplate } from "../service" + +// Validation Schema +const duplicateTemplateSchema = z.object({ + name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"), + slug: z.string() + .min(1, "Slug는 필수입니다") + .max(50, "Slug는 50자 이하여야 합니다") + .regex(/^[a-z0-9-]+$/, "Slug는 소문자, 숫자, 하이픈만 사용 가능합니다"), +}) + +type DuplicateTemplateSchema = z.infer<typeof duplicateTemplateSchema> + +interface DuplicateTemplateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + template: TemplateListView | null +} + +export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplateSheetProps) { + const [isDuplicatePending, startDuplicateTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<DuplicateTemplateSchema>({ + resolver: zodResolver(duplicateTemplateSchema), + defaultValues: { + name: "", + slug: "", + }, + }) + + React.useEffect(() => { + if (template) { + const copyName = `${template.name} (복사본)` + const copySlug = `${template.slug}-copy-${Date.now()}` + + form.reset({ + name: copyName, + slug: copySlug, + }) + } + }, [template, form]) + + // 이름 입력 시 자동으로 slug 생성 + const watchedName = form.watch("name") + React.useEffect(() => { + if (watchedName && !form.formState.dirtyFields.slug) { + const autoSlug = watchedName + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() + .slice(0, 50) + + form.setValue("slug", autoSlug, { shouldValidate: false }) + } + }, [watchedName, form]) + + function onSubmit(input: DuplicateTemplateSchema) { + startDuplicateTransition(async () => { + if (!template) return + + // 현재 사용자 ID (실제로는 인증에서 가져와야 함) + const currentUserId = "current-user-id" // TODO: 실제 사용자 ID로 교체 + + const { error, data } = await duplicateTemplate( + template.id, + input.name, + input.slug, + currentUserId + ) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("템플릿이 복제되었습니다") + + // 복제된 템플릿의 세부 페이지로 이동 + if (data?.slug) { + router.push(`/evcp/templates/${data.slug}`) + } else { + window.location.reload() + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>템플릿 복제</SheetTitle> + <SheetDescription> + 기존 템플릿을 복사하여 새로운 템플릿을 생성합니다. 모든 내용과 변수가 복제됩니다. + </SheetDescription> + </SheetHeader> + + {template && ( + <div className="rounded-lg bg-muted p-3"> + <h4 className="text-sm font-medium mb-2">원본 템플릿</h4> + <div className="space-y-1 text-xs text-muted-foreground"> + <div>이름: {template.name}</div> + <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div> + <div>카테고리: {template.categoryDisplayName}</div> + <div>변수: {template.variableCount}개</div> + </div> + </div> + )} + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>새 템플릿 이름</FormLabel> + <FormControl> + <Input + placeholder="복제된 템플릿의 이름을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="slug" + render={({ field }) => ( + <FormItem> + <FormLabel>새 Slug</FormLabel> + <FormControl> + <Input + placeholder="복제된 템플릿의 slug를 입력하세요" + {...field} + /> + </FormControl> + <FormDescription> + 고유한 식별자여야 합니다. 소문자, 숫자, 하이픈만 사용 가능합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isDuplicatePending}> + {isDuplicatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 복제하기 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/email-template/table/email-template-table.tsx b/lib/email-template/table/email-template-table.tsx new file mode 100644 index 00000000..65e93a10 --- /dev/null +++ b/lib/email-template/table/email-template-table.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { TemplateTableToolbarActions } from "./template-table-toolbar-actions" +import { getColumns } from "./template-table-columns" +import { type TemplateListView } from "@/db/schema" +import { getTemplateList } from "../service" +import { UpdateTemplateSheet } from "./update-template-sheet" +import { CreateTemplateSheet } from "./create-template-sheet" +import { DuplicateTemplateSheet } from "./duplicate-template-sheet" +import { DeleteTemplateDialog } from "./delete-template-dialog" + +interface TemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTemplateList>>, + ] + > +} + +export function TemplateTable({ promises }: TemplateTableProps) { + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TemplateListView> | null>(null) + + const [showCreateSheet, setShowCreateSheet] = React.useState(false) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * 기본 필터 필드 (드롭다운 형태) + */ + const filterFields: DataTableFilterField<TemplateListView>[] = [ + + ] + + /** + * 고급 필터 필드 (검색, 날짜 등) + */ + const advancedFilterFields: DataTableAdvancedFilterField<TemplateListView>[] = [ + { + id: "name", + label: "템플릿 이름", + type: "text", + }, + { + id: "slug", + label: "Slug", + type: "text", + }, + + { + id: "variableCount", + label: "변수 개수", + type: "text", + }, + { + id: "version", + label: "버전", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + columnVisibility: { + slug: false, // 기본적으로 slug 컬럼은 숨김 + }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TemplateTableToolbarActions + table={table} + onCreateTemplate={() => setShowCreateSheet(true)} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 새 템플릿 생성 Sheet */} + <CreateTemplateSheet + open={showCreateSheet} + onOpenChange={setShowCreateSheet} + /> + + {/* 템플릿 수정 Sheet */} + <UpdateTemplateSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + template={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* 템플릿 복제 Sheet */} + <DuplicateTemplateSheet + open={rowAction?.type === "duplicate"} + onOpenChange={() => setRowAction(null)} + template={rowAction?.type === "duplicate" ? rowAction.row.original : null} + /> + + {/* 템플릿 삭제 Dialog */} + <DeleteTemplateDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + templates={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 새로고침은 server action에서 자동으로 처리됨 + }} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/email-template/table/template-table-columns.tsx b/lib/email-template/table/template-table-columns.tsx new file mode 100644 index 00000000..d20739cc --- /dev/null +++ b/lib/email-template/table/template-table-columns.tsx @@ -0,0 +1,296 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown, Copy, MoreHorizontal, Edit, Trash, Eye } from "lucide-react" +import Link from "next/link" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { toast } from "sonner" +import { formatDate } from "@/lib/utils" +import { type TemplateListView } from "@/db/schema" +import { type DataTableRowAction } from "@/types/table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { getCategoryDisplayName, getCategoryVariant } from "../validations" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TemplateListView> | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TemplateListView>[] { + return [ + // 체크박스 컬럼 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + + // 템플릿 이름 컬럼 (클릭 시 세부 페이지로) + { + accessorKey: "name", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="템플릿 이름" /> + ), + cell: ({ row }) => { + const template = row.original + return ( + <div className="flex flex-col gap-1"> + <Link + href={`/evcp/email-template/${template.slug}`} + className="font-medium text-blue-600 hover:text-blue-800 hover:underline" + > + {template.name} + </Link> + {template.description && ( + <div className="text-xs text-muted-foreground line-clamp-2"> + {template.description} + </div> + )} + </div> + ) + }, + enableSorting: true, + enableHiding: false, + size:200 + }, + + { + accessorKey: "subject", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="이메일 제목" /> + ), + cell: ({ getValue }) => { + const subject = getValue() as string + return ( + <div className="text-sm text-muted-foreground"> + {subject} + </div> + ) + }, + enableSorting: true, + size:250 + }, + + // Slug 컬럼 + { + accessorKey: "slug", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Slug" /> + ), + cell: ({ getValue }) => { + const slug = getValue() as string + return ( + <div className="font-mono text-sm text-muted-foreground"> + {slug} + </div> + ) + }, + enableSorting: true, + size:120 + + }, + + // 카테고리 컬럼 + { + accessorKey: "category", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="카테고리" /> + ), + cell: ({ row }) => { + const category = row.original.category + const displayName = getCategoryDisplayName(category) + const variant = getCategoryVariant(category) + + return ( + <Badge variant={variant}> + {displayName} + </Badge> + ) + }, + enableSorting: true, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + size:120 + + }, + + // 변수 개수 컬럼 + { + accessorKey: "variableCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="변수" /> + ), + cell: ({ row }) => { + const variableCount = row.original.variableCount + const requiredCount = row.original.requiredVariableCount + + return ( + <div className="text-center"> + <div className="text-sm font-medium">{variableCount}</div> + {requiredCount > 0 && ( + <div className="text-xs text-muted-foreground"> + 필수: {requiredCount} + </div> + )} + </div> + ) + }, + enableSorting: true, + size:80 + + }, + + // 버전 컬럼 + { + accessorKey: "version", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="버전" /> + ), + cell: ({ getValue }) => { + const version = getValue() as number + return ( + <div className="text-center"> + <Badge variant="outline" className="font-mono text-xs"> + v{version} + </Badge> + </div> + ) + }, + enableSorting: true, + size:80 + + }, + + // 생성일 컬럼 + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ cell }) => { + const date = cell.getValue() as Date + return ( + <div className="text-sm text-muted-foreground"> + {formatDate(date)} + </div> + ) + }, + enableSorting: true, + size:200 + + }, + + // 수정일 컬럼 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ cell }) => { + const date = cell.getValue() as Date + return ( + <div className="text-sm text-muted-foreground"> + {formatDate(date)} + </div> + ) + }, + enableSorting: true, + size:200 + + }, + + // Actions 컬럼 + { + id: "actions", + cell: ({ row }) => { + const template = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <MoreHorizontal className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem asChild> + <Link href={`/evcp/templates/${template.slug}`}> + <Eye className="mr-2 size-4" aria-hidden="true" /> + 보기 + </Link> + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "update", row }) + }} + > + <Edit className="mr-2 size-4" aria-hidden="true" /> + 수정 + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "duplicate", row }) + }} + > + <Copy className="mr-2 size-4" aria-hidden="true" /> + 복제 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "delete", row }) + }} + className="text-destructive focus:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableSorting: false, + enableHiding: false, + size:80 + + }, + ] +}
\ No newline at end of file diff --git a/lib/email-template/table/template-table-toolbar-actions.tsx b/lib/email-template/table/template-table-toolbar-actions.tsx new file mode 100644 index 00000000..a7e107c6 --- /dev/null +++ b/lib/email-template/table/template-table-toolbar-actions.tsx @@ -0,0 +1,115 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Plus, Trash } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { type TemplateListView } from "@/db/schema/template-views" +import { DeleteTemplateDialog } from "./delete-template-dialog" +import { toast } from "sonner" + +interface TemplateTableToolbarActionsProps { + table: Table<TemplateListView> + onCreateTemplate: () => void +} + +export function TemplateTableToolbarActions({ + table, + onCreateTemplate, +}: TemplateTableToolbarActionsProps) { + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedTemplates = selectedRows.map(row => row.original) + + // CSV 내보내기 함수 + const exportToCsv = React.useCallback(() => { + const headers = ['이름', 'Slug', '카테고리', '변수 개수', '버전', '생성일', '수정일'] + const csvData = [ + headers, + ...table.getFilteredRowModel().rows.map(row => { + const template = row.original + return [ + template.name, + template.slug, + template.categoryDisplayName || '미분류', + template.variableCount.toString(), + template.version.toString(), + new Date(template.createdAt).toLocaleDateString('ko-KR'), + new Date(template.updatedAt).toLocaleDateString('ko-KR'), + ] + }) + ] + + const csvContent = csvData.map(row => + row.map(field => `"${field}"`).join(',') + ).join('\n') + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + + if (link.download !== undefined) { + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', `templates_${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + toast.success('템플릿 목록이 CSV로 내보내졌습니다.') + }, [table]) + + return ( + <div className="flex items-center gap-2"> + {/* 새 템플릿 생성 버튼 */} + <Button + variant="default" + size="sm" + onClick={onCreateTemplate} + > + <Plus className="mr-2 size-4" aria-hidden="true" /> + 새 템플릿 + </Button> + + {/* CSV 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={exportToCsv} + > + <Download className="mr-2 size-4" aria-hidden="true" /> + 내보내기 + </Button> + + {/* 선택된 항목 일괄 삭제 버튼 */} + {selectedTemplates.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setShowDeleteDialog(true)} + className="text-destructive hover:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({selectedTemplates.length}) + </Button> + + {/* 일괄 삭제 Dialog */} + <DeleteTemplateDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + templates={selectedTemplates} + showTrigger={false} + onSuccess={() => { + table.toggleAllRowsSelected(false) + setShowDeleteDialog(false) + }} + /> + </> + )} + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/table/update-template-sheet.tsx b/lib/email-template/table/update-template-sheet.tsx new file mode 100644 index 00000000..58da0626 --- /dev/null +++ b/lib/email-template/table/update-template-sheet.tsx @@ -0,0 +1,215 @@ +"use client" + +import * as React from "react" +import { type TemplateListView} from "@/db/schema" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { updateTemplateAction } from "../service" +import { TEMPLATE_CATEGORY_OPTIONS } from "../validations" + +// Validation Schema +const updateTemplateSchema = z.object({ + name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"), + description: z.string().max(500, "설명은 500자 이하여야 합니다").optional(), + category: z.string().optional(), +}) + +type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema> + +interface UpdateTemplateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + template: TemplateListView | null +} + +export function UpdateTemplateSheet({ template, ...props }: UpdateTemplateSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateTemplateSchema>({ + resolver: zodResolver(updateTemplateSchema), + defaultValues: { + name: template?.name ?? "", + description: template?.description ?? "", + category: template?.category ?? "", + }, + }) + + React.useEffect(() => { + if (template) { + form.reset({ + name: template.name ?? "", + description: template.description ?? "", + category: template.category ?? "", + }) + } + }, [template, form]) + + function onSubmit(input: UpdateTemplateSchema) { + startUpdateTransition(async () => { + if (!template) return + + // 현재 사용자 ID (실제로는 인증에서 가져와야 함) + const currentUserId = "current-user-id" // TODO: 실제 사용자 ID로 교체 + + const { error } = await updateTemplateAction(template.slug, { + name: input.name, + description: input.description || undefined, + // category는 일반적으로 수정하지 않는 것이 좋지만, 필요시 포함 + updatedBy: currentUserId, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("템플릿이 업데이트되었습니다") + + // 페이지 새로고침으로 데이터 갱신 + window.location.reload() + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>템플릿 수정</SheetTitle> + <SheetDescription> + 템플릿의 기본 정보를 수정할 수 있습니다. 템플릿 내용을 수정하려면 세부 페이지에서 편집하세요. + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>템플릿 이름</FormLabel> + <FormControl> + <Input + placeholder="템플릿 이름을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="템플릿에 대한 설명을 입력하세요" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="">카테고리 없음</SelectItem> + {TEMPLATE_CATEGORY_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현재 정보 표시 */} + {template && ( + <div className="rounded-lg bg-muted p-3"> + <h4 className="text-sm font-medium mb-2">현재 정보</h4> + <div className="space-y-1 text-xs text-muted-foreground"> + <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div> + <div>버전: v{template.version}</div> + <div>변수: {template.variableCount}개 (필수: {template.requiredVariableCount}개)</div> + <div>수정일: {new Date(template.updatedAt).toLocaleString('ko-KR')}</div> + </div> + </div> + )} + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/email-template/validations.ts b/lib/email-template/validations.ts new file mode 100644 index 00000000..ece5437e --- /dev/null +++ b/lib/email-template/validations.ts @@ -0,0 +1,82 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { templateListView, type TemplateListView } from '@/db/schema'; + +export const SearchParamsEmailTemplateCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<typeof templateListView>().withDefault([ + { id: "updatedAt", desc: true }]), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); +export type GetEmailTemplateSchema = Awaited<ReturnType<typeof SearchParamsEmailTemplateCache.parse>>; + + +// 카테고리 옵션과 유틸리티 함수들 (애플리케이션 레벨에서 처리) +export const TEMPLATE_CATEGORY_OPTIONS = [ + { label: '환영 메일', value: 'welcome-email' }, + { label: '비밀번호 재설정', value: 'password-reset' }, + { label: '알림', value: 'notification' }, + { label: '청구서', value: 'invoice' }, + { label: '마케팅', value: 'marketing' }, + { label: '시스템', value: 'system' }, +] as const; + +// 카테고리 변환 함수 (애플리케이션 레벨) +export function getCategoryDisplayName(category: string | null): string { + const categoryMap: Record<string, string> = { + 'welcome-email': '환영 메일', + 'password-reset': '비밀번호 재설정', + 'notification': '알림', + 'invoice': '청구서', + 'marketing': '마케팅', + 'system': '시스템', + }; + + return categoryMap[category || ''] || '미분류'; +} + +// 카테고리 변형(variant) 함수 +export function getCategoryVariant(category: string | null): 'default' | 'secondary' | 'destructive' | 'outline' { + const variantMap: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { + 'welcome-email': 'default', + 'password-reset': 'destructive', + 'notification': 'secondary', + 'invoice': 'outline', + 'marketing': 'default', + 'system': 'secondary', + }; + + return variantMap[category || ''] || 'outline'; +} + +// 확장된 템플릿 타입 (display name 포함) +export type TemplateListViewWithDisplay = TemplateListView & { + categoryDisplayName: string; + categoryVariant: 'default' | 'secondary' | 'destructive' | 'outline'; +}; + +// 유틸리티 함수: View 데이터에 display 정보 추가 +export function enhanceTemplateListView(template: TemplateListView): TemplateListViewWithDisplay { + return { + ...template, + categoryDisplayName: getCategoryDisplayName(template.category), + categoryVariant: getCategoryVariant(template.category), + }; +}
\ No newline at end of file diff --git a/lib/file-download-log/service.ts b/lib/file-download-log/service.ts index b2350782..4e147b3d 100644 --- a/lib/file-download-log/service.ts +++ b/lib/file-download-log/service.ts @@ -287,7 +287,7 @@ export async function getAllDownloadLogs( }> { try { const session = await getServerSession(authOptions); - if (!session?.user || !session.user.roles.includes('admin') ) { + if (!session?.user || !session?.user?.roles.includes('admin') ) { throw new Error('관리자 권한이 필요합니다.'); } diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts index 3b358ea8..408f6e40 100644 --- a/lib/mail/sendEmail.ts +++ b/lib/mail/sendEmail.ts @@ -1,72 +1,320 @@ import { useTranslation } from '@/i18n'; -import { transporter, loadTemplate } from './mailer'; +import { transporter } from './mailer'; +import db from '@/db/db'; +import { templateDetailView } from '@/db/schema'; +import { eq } from 'drizzle-orm'; import handlebars from 'handlebars'; +import fs from 'fs'; +import path from 'path'; interface SendEmailOptions { to: string; - subject: string; - template: string; // 템플릿 파일명(확장자 제외) - context: Record<string, any>; // 템플릿에 주입할 데이터 - cc?: string | string[]; // cc 필드 추가 - 단일 이메일 또는 이메일 배열 - from?: string; // from 필드 추가 - 옵셔널 + subject?: string; // 🆕 이제 선택적, 템플릿에서 가져올 수 있음 + template: string; // 템플릿 slug + context: Record<string, any>; + cc?: string | string[]; + from?: string; attachments?: { - // NodeMailer "Attachment" 타입 - filename?: string - path?: string - content?: Buffer | string - // ... - }[] + filename?: string; + path?: string; + content?: Buffer | string; + }[]; +} + +interface TemplateValidationError { + field: string; + message: string; +} + +interface TemplateRenderResult { + subject: string; + html: string; + validationErrors: TemplateValidationError[]; } +// 템플릿 캐시 (성능 최적화) +const templateCache = new Map<string, { + subject: string; + content: string; + variables: any[]; + cachedAt: number; +}>(); + +const CACHE_DURATION = 5 * 60 * 1000; // 5분 + export async function sendEmail({ to, - subject, + subject, // 이제 선택적 매개변수 template, context, - cc, // cc 매개변수 추가 - from, // from 매개변수 추가 + cc, + from, attachments = [] }: SendEmailOptions) { try { // i18n 설정 const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); - // t 헬퍼를 언어별로 동적으로 재등록 (기존 헬퍼 덮어쓰기) - // 헬퍼가 이미 등록되어 있더라도 안전하게 재등록 - handlebars.unregisterHelper('t'); // 기존 헬퍼 제거 + // Handlebars 헬퍼 등록 + handlebars.unregisterHelper('t'); handlebars.registerHelper("t", function (key: string, options: any) { - // 여기서 i18n은 로컬 인스턴스 return i18n.t(key, options?.hash || {}); }); - // 템플릿 데이터에 i18n 인스턴스와 번역 함수 추가 + // 템플릿 데이터 준비 const templateData = { ...context, t: (key: string, options?: any) => i18n.t(key, options || {}), i18n: i18n }; - // 템플릿 컴파일 및 HTML 생성 - const html = loadTemplate(template, templateData); + // 템플릿에서 subject와 content 모두 로드 + const { subject: renderedSubject, html, validationErrors } = + await loadAndRenderTemplate(template, templateData, subject); + + if (validationErrors.length > 0) { + console.warn(`템플릿 검증 경고 (${template}):`, validationErrors); + } - // from 값 설정 - 매개변수가 있으면 사용, 없으면 기본값 사용 + // from 주소 설정 const fromAddress = from || `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`; // 이메일 발송 const result = await transporter.sendMail({ from: fromAddress, to, - cc, // cc 필드 추가 - subject, + cc, + subject: renderedSubject, // 템플릿에서 렌더링된 subject 사용 html, attachments }); - console.log(`이메일 발송 성공: ${to}`, result.messageId); + console.log(`이메일 발송 성공: ${to}`, { + messageId: result.messageId, + subject: renderedSubject, + template + }); + return result; - + } catch (error) { console.error(`이메일 발송 실패: ${to}`, error); throw error; } +} + +async function loadAndRenderTemplate( + templateSlug: string, + data: Record<string, unknown>, + fallbackSubject?: string +): Promise<TemplateRenderResult> { + try { + // 캐시 확인 + const cached = templateCache.get(templateSlug); + const now = Date.now(); + + let templateInfo; + + if (cached && (now - cached.cachedAt) < CACHE_DURATION) { + templateInfo = cached; + } else { + // 데이터베이스에서 템플릿 조회 + const templates = await db + .select() + .from(templateDetailView) + .where(eq(templateDetailView.slug, templateSlug)) + .limit(1); + + if (templates.length === 0) { + // 폴백: 파일 기반 템플릿 시도 + return await loadFileBasedTemplate(templateSlug, data, fallbackSubject); + } + + const template = templates[0]; + + // 비활성화된 템플릿 확인 + if (!template.isActive) { + throw new Error(`Template '${templateSlug}' is inactive`); + } + + templateInfo = { + subject: template.subject, + content: template.content, + variables: template.variables as any[], + cachedAt: now + }; + + // 캐시 저장 + templateCache.set(templateSlug, templateInfo); + } + + // 변수 검증 + const validationErrors = validateTemplateVariables(templateInfo.variables, data); + + // Subject와 Content 모두 Handlebars로 렌더링 + const subjectTemplate = handlebars.compile(templateInfo.subject); + const contentTemplate = handlebars.compile(templateInfo.content); + + const subject = subjectTemplate(data); + const html = contentTemplate(data); + + return { subject, html, validationErrors }; + + } catch (error) { + console.error(`템플릿 로드 실패: ${templateSlug}`, error); + + // 최종 폴백: 파일 기반 템플릿 시도 + try { + return await loadFileBasedTemplate(templateSlug, data, fallbackSubject); + } catch (fallbackError) { + throw new Error(`Template loading failed for '${templateSlug}': ${fallbackError}`); + } + } +} + +// 변수 검증 함수 (기존과 동일) +function validateTemplateVariables( + templateVariables: any[], + providedData: Record<string, unknown> +): TemplateValidationError[] { + const errors: TemplateValidationError[] = []; + + for (const variable of templateVariables) { + const { variableName, isRequired, variableType } = variable; + const value = providedData[variableName]; + + // 필수 변수 검증 + if (isRequired && (value === undefined || value === null || value === '')) { + errors.push({ + field: variableName, + message: `Required variable '${variableName}' is missing` + }); + continue; + } + + // 타입 검증 (값이 제공된 경우에만) + if (value !== undefined && value !== null) { + const typeError = validateVariableType(variableName, value, variableType); + if (typeError) { + errors.push(typeError); + } + } + } + + return errors; +} + +// 변수 타입 검증 (기존과 동일) +function validateVariableType( + variableName: string, + value: unknown, + expectedType: string +): TemplateValidationError | null { + switch (expectedType) { + case 'string': + if (typeof value !== 'string') { + return { + field: variableName, + message: `Variable '${variableName}' should be a string, got ${typeof value}` + }; + } + break; + case 'number': + if (typeof value !== 'number' && !Number.isFinite(Number(value))) { + return { + field: variableName, + message: `Variable '${variableName}' should be a number` + }; + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + return { + field: variableName, + message: `Variable '${variableName}' should be a boolean` + }; + } + break; + case 'date': + if (!(value instanceof Date) && isNaN(Date.parse(String(value)))) { + return { + field: variableName, + message: `Variable '${variableName}' should be a valid date` + }; + } + break; + } + return null; +} + +// 기존 파일 기반 템플릿 로더 (호환성 유지) +async function loadFileBasedTemplate( + templateName: string, + data: Record<string, unknown>, + fallbackSubject?: string +): Promise<TemplateRenderResult> { + const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Template not found: ${templatePath}`); + } + + const source = fs.readFileSync(templatePath, 'utf8'); + const compiledTemplate = handlebars.compile(source); + const html = compiledTemplate(data); + + // 파일 기반에서는 fallback subject 사용 + const subject = fallbackSubject || `Email from ${process.env.Email_From_Name || 'System'}`; + + return { subject, html, validationErrors: [] }; +} + +// 이전 버전과의 호환성을 위한 래퍼 함수 +export async function sendEmailLegacy({ + to, + subject, + template, + context, + cc, + from, + attachments = [] +}: Required<Pick<SendEmailOptions, 'subject'>> & Omit<SendEmailOptions, 'subject'>) { + return await sendEmail({ + to, + subject, + template, + context, + cc, + from, + attachments + }); +} + +// 템플릿 캐시 클리어 (관리자 기능용) +export function clearTemplateCache(templateSlug?: string) { + if (templateSlug) { + templateCache.delete(templateSlug); + } else { + templateCache.clear(); + } +} + +// 템플릿 미리보기 함수 (개발/테스트용) +export async function previewTemplate( + templateSlug: string, + sampleData: Record<string, unknown> +): Promise<TemplateRenderResult> { + return await loadAndRenderTemplate(templateSlug, sampleData); +} + +// Subject만 미리보기하는 함수 +export async function previewSubject( + templateSlug: string, + sampleData: Record<string, unknown> +): Promise<{ subject: string; validationErrors: TemplateValidationError[] }> { + const result = await loadAndRenderTemplate(templateSlug, sampleData); + return { + subject: result.subject, + validationErrors: result.validationErrors + }; }
\ No newline at end of file diff --git a/lib/mail/service.ts b/lib/mail/service.ts deleted file mode 100644 index cbd02953..00000000 --- a/lib/mail/service.ts +++ /dev/null @@ -1,380 +0,0 @@ -'use server';
-
-import fs from 'fs';
-import path from 'path';
-import handlebars from 'handlebars';
-import i18next from 'i18next';
-import resourcesToBackend from 'i18next-resources-to-backend';
-import { getOptions } from '@/i18n/settings';
-
-// Types
-export interface TemplateFile {
- name: string;
- content: string;
- lastModified: string;
- path: string;
-}
-
-interface UpdateTemplateRequest {
- name: string;
- content: string;
-}
-
-interface TemplateValidationResult {
- isValid: boolean;
- errors: string[];
- warnings: string[];
-}
-
-// Configuration
-const baseDir = path.join(process.cwd(), 'lib', 'mail');
-const templatesDir = path.join(baseDir, 'templates');
-
-let initialized = false;
-
-function getFilePath(name: string): string {
- const fileName = name.endsWith('.hbs') ? name : `${name}.hbs`;
- return path.join(templatesDir, fileName);
-}
-
-// Initialization
-async function initializeAsync(): Promise<void> {
- if (initialized) return;
-
- try {
- // i18next 초기화 (서버 사이드)
- if (!i18next.isInitialized) {
- await i18next
- .use(resourcesToBackend((language: string, namespace: string) =>
- import(`@/i18n/locales/${language}/${namespace}.json`)
- ))
- .init(getOptions());
- }
-
- // Handlebars 헬퍼 등록
- 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 {
- // i18n 번역 헬퍼
- 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);
- });
-}
-
-// Validation
-function validateTemplate(content: string): TemplateValidationResult {
- const errors: string[] = [];
- const warnings: string[] = [];
-
- try {
- // Handlebars 문법 검사
- handlebars.compile(content);
- } catch (error) {
- if (error instanceof Error) {
- errors.push(`Handlebars 문법 오류: ${error.message}`);
- }
- }
-
- // 일반적인 문제 확인
- if (content.trim().length === 0) {
- errors.push('템플릿 내용이 비어있습니다.');
- }
-
- // 위험한 패턴 확인
- if (content.includes('<script>')) {
- warnings.push('스크립트 태그가 포함되어 있습니다. 보안에 주의하세요.');
- }
-
- return {
- isValid: errors.length === 0,
- errors,
- warnings
- };
-}
-
-// Core Functions
-async function getTemplateList(): Promise<TemplateFile[]> {
- try {
- if (!fs.existsSync(templatesDir)) {
- return [];
- }
-
- const files = fs.readdirSync(templatesDir).filter(file => file.endsWith('.hbs'));
- const allTemplates: TemplateFile[] = [];
-
- for (const file of files) {
- const filePath = path.join(templatesDir, file);
-
- try {
- const stats = fs.statSync(filePath);
- const content = fs.readFileSync(filePath, 'utf8');
-
- allTemplates.push({
- name: file.replace('.hbs', ''),
- content,
- lastModified: stats.mtime.toISOString(),
- path: filePath
- });
- } catch (fileError) {
- console.error(`❌ 파일 읽기 실패: ${filePath}`, fileError);
- }
- }
-
- return allTemplates.sort((a, b) => a.name.localeCompare(b.name));
- } catch (error) {
- console.error('Error getting template list:', error);
- throw new Error('템플릿 목록을 가져오는데 실패했습니다.');
- }
-}
-
-async function getTemplate(name: string): Promise<TemplateFile | null> {
- try {
- const filePath = getFilePath(name);
-
- if (!fs.existsSync(filePath)) {
- return null;
- }
-
- const stats = fs.statSync(filePath);
- const content = fs.readFileSync(filePath, 'utf8');
-
- return {
- name: name.replace('.hbs', ''),
- content,
- lastModified: stats.mtime.toISOString(),
- path: filePath
- };
- } catch (error) {
- console.error(`❌ 템플릿 조회 실패 ${name}:`, error);
- throw new Error(`템플릿 ${name}을 가져오는데 실패했습니다.`);
- }
-}
-
-async function updateTemplate(request: UpdateTemplateRequest): Promise<TemplateFile> {
- try {
- const { name, content } = request;
-
- // 템플릿 유효성 검사
- const validation = validateTemplate(content);
- if (!validation.isValid) {
- throw new Error(`템플릿 유효성 검사 실패: ${validation.errors.join(', ')}`);
- }
-
- const filePath = getFilePath(name);
-
- if (!fs.existsSync(filePath)) {
- throw new Error(`템플릿 ${name}이 존재하지 않습니다.`);
- }
-
- fs.writeFileSync(filePath, content, 'utf8');
-
- const stats = fs.statSync(filePath);
-
- return {
- name: name.replace('.hbs', ''),
- content,
- lastModified: stats.mtime.toISOString(),
- path: filePath
- };
- } catch (error) {
- console.error('Error updating template:', error);
- if (error instanceof Error) {
- throw error;
- }
- throw new Error('템플릿 수정에 실패했습니다.');
- }
-}
-
-async function searchTemplates(query: string): Promise<TemplateFile[]> {
- try {
- const allTemplates = await getTemplateList();
- const lowerQuery = query.toLowerCase();
-
- return allTemplates.filter(template =>
- template.name.toLowerCase().includes(lowerQuery) ||
- template.content.toLowerCase().includes(lowerQuery)
- );
- } catch (error) {
- console.error('Error searching templates:', error);
- throw new Error('템플릿 검색에 실패했습니다.');
- }
-}
-
-async function previewTemplate(
- name: string,
- data?: Record<string, unknown>
-): Promise<string> {
- try {
- // 초기화 대기
- await ensureInitialized();
-
- const template = await getTemplate(name);
- if (!template) {
- throw new Error(`템플릿 ${name}이 존재하지 않습니다.`);
- }
-
- // 템플릿 컴파일
- const compiledTemplate = handlebars.compile(template.content);
- const content = compiledTemplate(data || {});
-
- return content;
- } catch (error) {
- console.error('Error previewing template:', error);
- throw new Error('템플릿 미리보기 생성에 실패했습니다.');
- }
-}
-
-// Server Actions
-export async function getTemplatesAction(search?: string) {
- try {
- let templates;
-
- if (search) {
- templates = await searchTemplates(search);
- } else {
- templates = await getTemplateList();
- }
-
- return {
- success: true,
- data: templates
- };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : '템플릿 목록을 가져오는데 실패했습니다.'
- };
- }
-}
-
-export async function getTemplateAction(name: string) {
- try {
- const template = await getTemplate(name);
-
- 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 updateTemplateAction(name: string, content: string) {
- try {
- if (!content) {
- return {
- success: false,
- error: '템플릿 내용이 필요합니다.'
- };
- }
-
- const template = await updateTemplate({
- name,
- content
- });
-
- return {
- success: true,
- data: template
- };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.'
- };
- }
-}
-
-export async function previewTemplateAction(
- name: string,
- data?: Record<string, unknown>
-) {
- try {
- if (!name) {
- return {
- success: false,
- error: '템플릿 이름이 필요합니다.'
- };
- }
-
- const result = await previewTemplate(name, data);
-
- return {
- success: true,
- data: {
- html: result
- }
- };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : '미리보기 생성에 실패했습니다.'
- };
- }
-}
\ No newline at end of file diff --git a/lib/qna/table/qna-detail.tsx b/lib/qna/table/qna-detail.tsx index 4f0a891f..84ca5ccd 100644 --- a/lib/qna/table/qna-detail.tsx +++ b/lib/qna/table/qna-detail.tsx @@ -50,10 +50,10 @@ import { deleteComment, updateComment, } from "@/lib/qna/service"; -import { Question } from "@/lib/qna/types"; +import { QnaViewSelect } from "@/db/schema"; interface QnaDetailProps { - question: Question; + question: QnaViewSelect; } export default function QnaDetail({ question }: QnaDetailProps) { diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts index d37b1890..d44e11f5 100644 --- a/lib/sedp/get-form-tags.ts +++ b/lib/sedp/get-form-tags.ts @@ -139,13 +139,15 @@ export async function importTagsFromSEDP( // tagClass 조회 (CLS_ID -> label) let tagClassLabel = firstTag.CLS_ID; // 기본값 if (firstTag.CLS_ID) { - const tagClassRecord = await tx.select({ label: tagClasses.label }) + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) // ✅ 수정 .from(tagClasses) .where(and( eq(tagClasses.code, firstTag.CLS_ID), eq(tagClasses.projectId, projectId) )) .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { tagClassLabel = tagClassRecord[0].label; @@ -230,7 +232,7 @@ export async function importTagsFromSEDP( // tagClass 조회 let tagClassLabel = firstTag.CLS_ID; if (firstTag.CLS_ID) { - const tagClassRecord = await tx.select({ label: tagClasses.label }) + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) // ✅ 수정 .from(tagClasses) .where(and( eq(tagClasses.code, firstTag.CLS_ID), @@ -386,7 +388,7 @@ export async function importTagsFromSEDP( let tagClassLabel = tagEntry.CLS_ID; // 기본값 let tagClassId = null; // 기본값 if (tagEntry.CLS_ID) { - const tagClassRecord = await tx.select({ id:tagClasses,id, label: tagClasses.label }) + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) // ✅ 수정 .from(tagClasses) .where(and( eq(tagClasses.code, tagEntry.CLS_ID), diff --git a/lib/utils.ts b/lib/utils.ts index 33b5a0c2..cb15e830 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -194,4 +194,84 @@ export function formatBytes(bytes: number, decimals: number = 2): string { const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i] -}
\ No newline at end of file +} + +export async function copyTextToClipboard(text: string) { + // 안전 가드: 브라우저 & API 지원 체크 + if (typeof navigator !== "undefined" && navigator.clipboard) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + /* fall through to fallback */ + } + } + + // --- Fallback (execCommand) --- + try { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.style.position = "fixed" // iOS + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + const ok = document.execCommand("copy") + document.body.removeChild(textarea) + return ok + } catch { + return false + } +} + +export function formatHtml(html: string): string { + if (!html.trim()) return html; + + // 한 줄짜리 HTML인지 확인 + const lineCount = html.split('\n').length; + const tagCount = (html.match(/<[^>]+>/g) || []).length; + + // 태그가 많은데 줄이 적으면 포맷팅 필요 + if (tagCount <= 3 || lineCount >= tagCount / 2) { + return html; // 이미 포맷팅된 것으로 보임 + } + + let formatted = html + // 태그 앞뒤 공백 정리 + .replace(/>\s*</g, '><') + // 블록 요소들 앞에 줄바꿈 + .replace(/(<\/?(div|p|h[1-6]|table|tr|td|th|ul|ol|li|header|footer|section|article)[^>]*>)/gi, '\n$1') + // 자체 닫는 태그 뒤에 줄바꿈 + .replace(/(<(br|hr|img|input)[^>]*\/?>)/gi, '$1\n') + // 여러 줄바꿈을 하나로 + .replace(/\n+/g, '\n') + .trim(); + + // 간단한 들여쓰기 + const lines = formatted.split('\n'); + let indent = 0; + const result: string[] = []; + + for (let line of lines) { + line = line.trim(); + if (!line) continue; + + // 닫는 태그면 들여쓰기 감소 + if (line.startsWith('</')) { + indent = Math.max(0, indent - 2); + } + + result.push(' '.repeat(indent) + line); + + // 여는 태그면 들여쓰기 증가 + if (line.startsWith('<') && !line.startsWith('</') && !line.endsWith('/>')) { + const voidTags = ['br', 'hr', 'img', 'input', 'meta', 'link']; + const tagName = line.match(/<(\w+)/)?.[1]?.toLowerCase(); + if (tagName && !voidTags.includes(tagName) && !line.includes(`</${tagName}>`)) { + indent += 2; + } + } + } + + return result.join('\n'); +} |
