diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index ad9aacd..e37c0b2 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -47,6 +47,12 @@ import ProductAttributesPage from './pages/ProductAttributesPage' import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage' import TiktokShopCasePage from './pages/TiktokShopCasePage' import ShopeeShopCasePage from './pages/ShopeeShopCasePage' +import BackupDashboardPage from './pages/backup/BackupDashboardPage' +import BackupDatabasePage from './pages/backup/BackupDatabasePage' +import BackupCatalogPage from './pages/backup/BackupCatalogPage' +import BackupOrdersPage from './pages/backup/BackupOrdersPage' +import BackupCmsPage from './pages/backup/BackupCmsPage' +import BackupMediaPage from './pages/backup/BackupMediaPage' function App() { return ( @@ -105,6 +111,13 @@ function App() { } /> } /> } /> + {/* Backup & Restore */} + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 7d60d14..03a33e7 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -30,6 +30,9 @@ import { Calculator, Info, ChevronLeft, + DatabaseBackup, + FileJson, + FolderArchive, } from 'lucide-react' const MAIN_MENU = [ @@ -96,6 +99,14 @@ const MENU_MANAGER_MENU = [ { icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' }, ] +const BACKUP_MENU = [ + { icon: DatabaseBackup, label: 'Database Backup', to: '/backup/database' }, + { icon: Package, label: 'Catalog Export', to: '/backup/catalog' }, + { icon: ShoppingCart, label: 'Orders Export', to: '/backup/orders' }, + { icon: FileJson, label: 'CMS Export', to: '/backup/cms' }, + { icon: FolderArchive, label: 'Media Files', to: '/backup/media' }, +] + const SETTINGS_MENU = [ { icon: Store, label: 'Store Settings', to: '/settings' }, { icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' }, @@ -121,6 +132,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p)) const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p)) const isInMenuManager = location.pathname.startsWith('/menu-manager') + const isInBackup = location.pathname.startsWith('/backup') const isInSettings = location.pathname.startsWith('/settings') const [productOpen, setProductOpen] = useState(isInProduct) @@ -132,6 +144,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const [cmsOpen, setCmsOpen] = useState(isInCms) const [customerOpen, setCustomerOpen] = useState(isInCustomer) const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager) + const [backupOpen, setBackupOpen] = useState(isInBackup) const [settingsOpen, setSettingsOpen] = useState(isInSettings) const handleLogout = () => { @@ -455,6 +468,36 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )} + {/* Backup & Restore Group */} +
+ + + {!isCollapsed && backupOpen && ( +
+ {BACKUP_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ {/* Settings Group */}
+ ))} +
+ + + {/* Format Selector */} +
+ +
+ {(['json', 'csv'] as ExportFormat[]).map((f) => ( + + ))} +
+ {format === 'json' && ( +

JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ

+ )} + {format === 'csv' && ( +

CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets

+ )} +
+ + {/* Export Button */} + + + {/* Result */} + {result && ( +
+ + {result.message} +
+ )} + + + ) +} diff --git a/apps/admin/src/pages/backup/BackupCmsPage.tsx b/apps/admin/src/pages/backup/BackupCmsPage.tsx new file mode 100644 index 0000000..44ce4cd --- /dev/null +++ b/apps/admin/src/pages/backup/BackupCmsPage.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { FileJson, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react' +import { NavLink } from 'react-router-dom' +import { API_BASE } from '@homaroma/types' + +type ExportSection = 'all' | 'pages' | 'menus' | 'articles' | 'settings' + +const SECTIONS: { key: ExportSection; label: string; description: string }[] = [ + { key: 'all', label: 'ทั้งหมด (Full CMS Export)', description: 'Pages + Menus + Articles + Store Settings' }, + { key: 'pages', label: 'Pages & Sections', description: 'Page builder blocks และ Section configs ทั้งหมด' }, + { key: 'menus', label: 'Navigation Menus', description: 'Main Navigation + Footer Menus (Explore / Support / Brand)' }, + { key: 'articles', label: 'Articles (Scent Guide)', description: 'Articles, content และ Translations' }, + { key: 'settings', label: 'Store Settings', description: 'StoreSettings + Theme tokens + Translations' }, +] + +export default function BackupCmsPage() { + const [selectedSection, setSelectedSection] = useState('all') + const [loading, setLoading] = useState(false) + const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + + const handleExport = async () => { + setLoading(true) + setResult(null) + try { + const res = await fetch(`${API_BASE}/backup/cms?section=${selectedSection}`) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Export failed' })) + setResult({ success: false, message: err.error || 'Export failed' }) + return + } + const blob = await res.blob() + const filename = `homaroma_cms_${selectedSection}_${new Date().toISOString().slice(0, 10)}.json` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` }) + } catch { + setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' }) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Breadcrumb */} + + + Backup & Restore + + + {/* Header */} +
+
+ +
+
+

CMS & Content Export

+

+ Export Pages/Sections, Menus, Articles และ Store Settings เป็น JSON +

+
+
+ + {/* Phase 2 notice */} +
+ +

+ ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} + GET /backup/cms{' '} + ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API +

+
+ +
+ {/* What's included detail */} +
+

ข้อมูลที่รวมอยู่ใน CMS Export

+
+ {[ + 'Home Page sections', 'Shop Page sections', + 'Product Detail sections', 'Category Page sections', + 'All dynamic CMS pages', 'Main Navigation tree', + 'Footer Explore menu', 'Footer Support menu', + 'Footer Brand info', 'Articles (Scent Guide)', + 'Store Settings', 'Theme tokens & colors', + 'Newsletter settings', 'Footer copy & translations', + ].map((item) => ( +
+
+ {item} +
+ ))} +
+
+ + {/* Section Selector */} +
+ +
+ {SECTIONS.map((s) => ( + + ))} +
+
+ + {/* Export Button — JSON only for CMS */} +
+

Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)

+ +
+ + {/* Result */} + {result && ( +
+ + {result.message} +
+ )} +
+
+ ) +} diff --git a/apps/admin/src/pages/backup/BackupDashboardPage.tsx b/apps/admin/src/pages/backup/BackupDashboardPage.tsx new file mode 100644 index 0000000..8013204 --- /dev/null +++ b/apps/admin/src/pages/backup/BackupDashboardPage.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react' +import { NavLink } from 'react-router-dom' +import { + DatabaseBackup, + Package, + ShoppingCart, + FileJson, + FolderArchive, + Shield, + Clock, + AlertTriangle, + ChevronRight, + Info, +} from 'lucide-react' + +interface BackupCard { + icon: React.ElementType + title: string + description: string + to: string + accentColor: string + badgeText?: string + badgeColor?: string + note?: string +} + +const BACKUP_CARDS: BackupCard[] = [ + { + icon: DatabaseBackup, + title: 'Full Database Backup', + description: + 'Export PostgreSQL dump ทั้งหมด (.sql) ครอบคลุมทุกตาราง — ใช้สำหรับ Disaster Recovery', + to: '/backup/database', + accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20', + badgeText: 'CRITICAL', + badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + note: 'ต้องการ backend endpoint (Phase 2)', + }, + { + icon: Package, + title: 'Catalog Export', + description: + 'Products, Variants, Categories, Attributes, Brands และ Translations ทั้งหมด', + to: '/backup/catalog', + accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', + badgeText: 'CRITICAL', + badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + }, + { + icon: ShoppingCart, + title: 'Orders & Customers Export', + description: + 'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter', + to: '/backup/orders', + accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20', + badgeText: 'CRITICAL', + badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + }, + { + icon: FileJson, + title: 'CMS & Content Export', + description: + 'Pages/Sections, Menus (Main + Footer), Articles, Store Settings', + to: '/backup/cms', + accentColor: 'text-amber-400 bg-amber-500/10 border-amber-500/20', + badgeText: 'HIGH', + badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', + }, + { + icon: FolderArchive, + title: 'Media Files', + description: + 'ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ — download เป็น .zip', + to: '/backup/media', + accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', + badgeText: 'HIGH', + badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', + note: 'ต้องการ backend endpoint (Phase 2)', + }, +] + +export default function BackupDashboardPage() { + const [acknowledged, setAcknowledged] = useState(false) + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Backup & Restore

+

+ จัดการและดาวน์โหลดข้อมูลสำรองของระบบ HomAroma +

+
+
+
+ + {/* Warning Banner */} + {!acknowledged && ( +
+
+ +
+

+ คำแนะนำก่อนใช้งาน Backup +

+
    +
  • ควร Backup ก่อนทุกครั้งที่จะทำการเปลี่ยนแปลงข้อมูลสำคัญ
  • +
  • เก็บไฟล์ Backup ไว้ในที่ปลอดภัย — ไม่ควรเก็บไว้บนเครื่องเดียวกับ Production
  • +
  • ทดสอบ Restore บน Staging ก่อนใช้ในกรณีฉุกเฉิน
  • +
  • ข้อมูลบางส่วน (Database Full Backup, Media Files) ต้องการ Backend API ซึ่งจะเปิดใช้งานใน Phase 2
  • +
+ +
+
+
+ )} + + {/* Status Bar */} +
+
+
+ +
+
+

Status

+

ระบบพร้อมใช้งาน

+
+
+
+
+ +
+
+

Last Backup

+

ยังไม่มีประวัติ

+
+
+
+
+ +
+
+

Phase

+

1 — Manual Export

+
+
+
+ + {/* Backup Cards */} +
+ {BACKUP_CARDS.map((card) => ( + +
+
+
+ +
+
+

{card.title}

+ {card.badgeText && ( + + {card.badgeText} + + )} +
+
+ +
+ +

+ {card.description} +

+ + {card.note && ( +
+ + {card.note} +
+ )} +
+ ))} +
+ + {/* Footer Note */} +
+ Backup & Restore — Phase 1 (Manual Export) · Phase 2 จะเพิ่ม Scheduled Backup และ Server-side Storage +
+
+ ) +} diff --git a/apps/admin/src/pages/backup/BackupDatabasePage.tsx b/apps/admin/src/pages/backup/BackupDatabasePage.tsx new file mode 100644 index 0000000..11733e8 --- /dev/null +++ b/apps/admin/src/pages/backup/BackupDatabasePage.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { DatabaseBackup, AlertTriangle, CheckCircle2, Info, Terminal, ChevronLeft, Loader2, RefreshCw } from 'lucide-react' +import { NavLink } from 'react-router-dom' +import { API_BASE } from '@homaroma/types' + +interface DbStats { + scannedAt: string + tableCounts: Record + pgDumpNote: string +} + +export default function BackupDatabasePage() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadStats = async () => { + setLoading(true) + setError(null) + try { + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/database`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) { + setError('ไม่สามารถดึงข้อมูลได้') + return + } + const data = await res.json() + setStats(data) + } catch { + setError('Network error — ไม่สามารถเชื่อมต่อ backend') + } finally { + setLoading(false) + } + } + + useEffect(() => { loadStats() }, []) + + const totalRows = stats + ? Object.values(stats.tableCounts).reduce((a, b) => a + b, 0) + : 0 + + return ( +
+ {/* Breadcrumb */} + + + Backup & Restore + + + {/* Header */} +
+
+ +
+
+

Full Database Backup

+

+ ภาพรวมข้อมูลในฐานข้อมูล + คำแนะนำการ Backup ผ่าน SSH +

+
+
+ + {/* Live DB Stats */} +
+
+
+ +

+ Database Overview +

+
+ +
+ + {loading && ( +
+ + กำลังโหลดข้อมูล... +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {stats && !loading && ( + <> +
+
+

{totalRows.toLocaleString()}

+

Total Rows

+
+
+

{Object.keys(stats.tableCounts).length}

+

Tables Scanned

+
+
+

{new Date(stats.scannedAt).toLocaleTimeString('th-TH')}

+

Last Scanned

+
+
+ +
+ {Object.entries(stats.tableCounts).map(([table, count]) => ( +
+ {table.replace(/([A-Z])/g, ' $1').trim()} + {count.toLocaleString()} +
+ ))} +
+ +

+ Scanned at {new Date(stats.scannedAt).toLocaleString('th-TH')} +

+ + )} +
+ + {/* pg_dump instructions */} +
+
+ +

+ Manual Backup (ผ่าน SSH) +

+
+

+ สำหรับ Full Database Backup ให้ใช้วิธีนี้ผ่าน SSH บน Production Server: +

+
+

# SSH เข้า server

+

ssh ti@kitizone.com

+

# ตรวจสอบชื่อ database จาก .env

+

{'cat /home/ti/projects/homaroma-api/.env | grep DATABASE_URL'}

+

# สร้าง pg_dump (แทนที่ ด้วยชื่อจริง)

+

{'pg_dump -U postgres > ~/backup_$(date +%Y%m%d_%H%M).sql'}

+

# Download กลับมาเครื่อง local

+

{'scp ti@kitizone.com:~/backup_*.sql ./'}

+
+
+ +

Full pg_dump ต้องการ shell access — ไม่สามารถทำผ่าน Admin UI ได้ในขณะนี้

+
+
+ + {/* Phase 2 notice */} +
+ +

+ ข้อมูลสรุปด้านบนดึงมาจาก{' '} + GET /api/backup/database{' '} + — สำหรับ pg_dump ผ่าน UI จะต้องการ shell execution capability บน backend ซึ่งอยู่ใน roadmap ถัดไป +

+
+
+ ) +} diff --git a/apps/admin/src/pages/backup/BackupMediaPage.tsx b/apps/admin/src/pages/backup/BackupMediaPage.tsx new file mode 100644 index 0000000..da76895 --- /dev/null +++ b/apps/admin/src/pages/backup/BackupMediaPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import { FolderArchive, Download, ChevronLeft, Info, AlertTriangle, Loader2, CheckCircle2, Image, FileVideo, File } from 'lucide-react' +import { NavLink } from 'react-router-dom' +import { API_BASE } from '@homaroma/types' + +export default function BackupMediaPage() { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + + const handleDownload = async () => { + setLoading(true) + setResult(null) + try { + const res = await fetch(`${API_BASE}/backup/media/zip`) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Download failed' })) + setResult({ success: false, message: err.error || 'Download failed' }) + return + } + const blob = await res.blob() + const filename = `homaroma_media_${new Date().toISOString().slice(0, 10)}.zip` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` }) + } catch { + setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' }) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Breadcrumb */} + + + Backup & Restore + + + {/* Header */} +
+
+ +
+
+

Media Files Backup

+

+ Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip +

+
+
+ + {/* Phase 2 Notice */} +
+
+ +
+

Phase 2 Feature

+

+ Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2 +

+
+
+

+ เมื่อ Backend endpoint{' '} + GET /backup/media/zip{' '} + พร้อม ระบบจะ zip โฟลเดอร์{' '} + public/uploads/{' '} + และ stream ไฟล์กลับมา download โดยตรง +

+
+ + {/* What's included */} +
+

+ ไฟล์ที่จะถูก Backup +

+
+ {[ + { icon: Image, label: 'Product Images', desc: 'รูปภาพสินค้าทุกตัวที่อัปโหลดผ่าน Admin', color: 'text-cyan-400' }, + { icon: Image, label: 'Category Images', desc: 'รูป Thumbnail ของแต่ละหมวดหมู่สินค้า', color: 'text-violet-400' }, + { icon: Image, label: 'Article Images', desc: 'รูปประกอบบทความ Scent Guide', color: 'text-amber-400' }, + { icon: Image, label: 'CMS Media', desc: 'รูปที่ใช้ใน Page Builder sections', color: 'text-emerald-400' }, + { icon: FileVideo, label: 'Video Assets', desc: 'วิดีโอหรือ GIF ที่ใช้ใน hero sections', color: 'text-rose-400' }, + { icon: File, label: 'Other Assets', desc: 'ไฟล์อื่นๆ ใน public/uploads/', color: 'text-slate-400' }, + ].map(({ icon: Icon, label, desc, color }) => ( +
+ +
+

{label}

+

{desc}

+
+
+ ))} +
+
+ + {/* Size Warning */} +
+ +

+ ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า +

+
+ + {/* Manual SCP */} +
+

Manual Download (ผ่าน SSH/SCP)

+
+

# rsync media folder จาก production

+

{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}

+
+
+ + {/* Download Button */} +
+

+ เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด +

+ + + {result && ( +
+ + {result.message} +
+ )} +
+
+ ) +} diff --git a/apps/admin/src/pages/backup/BackupOrdersPage.tsx b/apps/admin/src/pages/backup/BackupOrdersPage.tsx new file mode 100644 index 0000000..4d8cf37 --- /dev/null +++ b/apps/admin/src/pages/backup/BackupOrdersPage.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { ShoppingCart, Download, CheckCircle2, ChevronLeft, Loader2, Info, AlertCircle } from 'lucide-react' +import { NavLink } from 'react-router-dom' +import { API_BASE } from '@homaroma/types' + +type ExportFormat = 'json' | 'csv' +type ExportSection = 'all' | 'orders' | 'customers' | 'coupons' | 'promotions' + +const SECTIONS: { key: ExportSection; label: string; description: string }[] = [ + { key: 'all', label: 'ทั้งหมด (Full Export)', description: 'Orders + Customers + Coupons + Promotions' }, + { key: 'orders', label: 'Orders', description: 'Orders พร้อม OrderItems และ Tracking' }, + { key: 'customers', label: 'Customers & Addresses', description: 'ข้อมูลลูกค้าและที่อยู่' }, + { key: 'coupons', label: 'Coupons', description: 'Coupon codes และ usage stats' }, + { key: 'promotions', label: 'Promotions', description: 'Promotion rules ทั้งหมด' }, +] + +function formatDate(d: Date) { + return d.toISOString().slice(0, 10) +} + +export default function BackupOrdersPage() { + const [selectedSection, setSelectedSection] = useState('all') + const [format, setFormat] = useState('json') + const [useDateRange, setUseDateRange] = useState(false) + const [dateFrom, setDateFrom] = useState(formatDate(new Date(Date.now() - 30 * 86400_000))) + const [dateTo, setDateTo] = useState(formatDate(new Date())) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + + const handleExport = async () => { + setLoading(true) + setResult(null) + try { + const params = new URLSearchParams({ section: selectedSection, format }) + if (useDateRange) { + params.set('from', dateFrom) + params.set('to', dateTo) + } + const res = await fetch(`${API_BASE}/backup/orders?${params}`) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Export failed' })) + setResult({ success: false, message: err.error || 'Export failed' }) + return + } + const blob = await res.blob() + const ext = format === 'json' ? 'json' : 'csv' + const suffix = useDateRange ? `${dateFrom}_to_${dateTo}` : 'all_time' + const filename = `homaroma_orders_${selectedSection}_${suffix}.${ext}` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` }) + } catch { + setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' }) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Breadcrumb */} + + + Backup & Restore + + + {/* Header */} +
+
+ +
+
+

Orders & Customers Export

+

+ Export Orders, Customers, Coupons และ Promotions พร้อม Date Range Filter +

+
+
+ + {/* Phase 2 notice */} +
+ +

+ ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} + GET /backup/orders{' '} + ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API +

+
+ +
+ {/* Section Selector */} +
+ +
+ {SECTIONS.map((s) => ( + + ))} +
+
+ + {/* Date Range */} +
+
+ + +
+ + {useDateRange && ( +
+
+ + setDateFrom(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50" + /> +
+
+ + setDateTo(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50" + /> +
+
+ )} + + {!useDateRange && ( +

Export ข้อมูลทุกช่วงเวลา (ไม่จำกัดวันที่)

+ )} +
+ + {/* Format Selector */} +
+ +
+ {(['json', 'csv'] as ExportFormat[]).map((f) => ( + + ))} +
+
+ + {/* Privacy note */} +
+ +

ข้อมูล Customer มีข้อมูลส่วนตัว — เก็บไฟล์ที่ Export ไว้ในที่ปลอดภัยและไม่แชร์โดยไม่จำเป็น

+
+ + {/* Export Button */} + + + {/* Result */} + {result && ( +
+ + {result.message} +
+ )} +
+
+ ) +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 41181b9..14f27bf 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -28,6 +28,7 @@ import { newsletterRoute } from "./routes/newsletter"; import { mediaRoute } from "./routes/media"; import { contactsRoute } from "./routes/contacts"; import { productAttributesRoute } from "./routes/product-attributes"; +import { backupRoute } from "./routes/backup"; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand"; @@ -135,7 +136,7 @@ const app = new Elysia() return file; }) .group("/api", (app) => - app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute) + app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute) ) .listen({ port: PORT, hostname: "0.0.0.0" }); diff --git a/apps/backend/src/routes/backup.ts b/apps/backend/src/routes/backup.ts new file mode 100644 index 0000000..d1cc748 --- /dev/null +++ b/apps/backend/src/routes/backup.ts @@ -0,0 +1,590 @@ +import { Elysia } from "elysia"; +import prisma from "../lib/prisma"; +import { adminOnly } from "../middleware/auth"; +import path from "path"; +import fs from "fs"; + +// ─── Helper: stream JSON as file download ──────────────────────────────────── + +function jsonResponse(data: unknown, filename: string) { + const json = JSON.stringify(data, null, 2); + const bytes = Buffer.from(json, "utf-8"); + return new Response(bytes, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Length": String(bytes.length), + }, + }); +} + +function csvResponse(rows: Record[], filename: string) { + if (!rows || rows.length === 0) { + return new Response("", { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } + const firstRow = rows[0]; + if (!firstRow) { + return new Response("", { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } + const headers = Object.keys(firstRow); + const csvLines = [ + headers.join(","), + ...rows.map((row) => + headers + .map((h) => { + const val = (row as any)[h]; + if (val === null || val === undefined) return ""; + const str = typeof val === "object" ? JSON.stringify(val) : String(val); + return `"${str.replace(/"/g, '""')}"`; + }) + .join(",") + ), + ]; + const csv = csvLines.join("\r\n"); + const bytes = Buffer.from(csv, "utf-8"); + return new Response(bytes, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Length": String(bytes.length), + }, + }); +} + +function today() { + return new Date().toISOString().slice(0, 10); +} + +// ─── Route ─────────────────────────────────────────────────────────────────── + +export const backupRoute = new Elysia({ prefix: "/backup" }) + .use(adminOnly) + + // ── 1. Catalog Export ────────────────────────────────────────────────────── + /** + * GET /api/backup/catalog?section=all|products|categories|attributes|brands&format=json|csv + * Exports product catalog data as JSON or CSV download. + */ + .get("/catalog", async ({ query, set }: { query: any; set: any }) => { + try { + const section = (query.section as string) || "all"; + const format = (query.format as string) === "csv" ? "csv" : "json"; + const dt = today(); + + if (section === "products" || section === "all") { + const products = await prisma.product.findMany({ + include: { + variants: true, + images: true, + translations: true, + attributes: { include: { attributeDefinition: true, translations: true } }, + brand: { include: { translations: true } }, + category: { select: { id: true, name: true } }, + }, + orderBy: { id: "asc" }, + }); + + if (section === "products") { + if (format === "csv") { + const rows = products.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + categoryId: p.categoryId, + brandId: p.brandId, + variantCount: p.variants.length, + imageCount: p.images.length, + createdAt: p.createdAt, + })); + return csvResponse(rows, `homaroma_products_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: products.length, data: products }, `homaroma_products_${dt}.json`); + } + + // full catalog + const categories = await prisma.category.findMany({ + where: { deletedAt: null }, + include: { translations: true }, + orderBy: { id: "asc" }, + }); + const attributes = await prisma.attributeDefinition.findMany({ + include: { translations: true }, + orderBy: { sortOrder: "asc" }, + }); + const brands = await prisma.productBrand.findMany({ + include: { translations: true }, + orderBy: { id: "asc" }, + }); + + if (format === "csv") { + // For CSV full export, flatten products only (complex nested data → JSON) + const rows = products.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + categoryId: p.categoryId, + categoryName: p.category?.name, + brandId: p.brandId, + brandName: p.brand?.name, + variantCount: p.variants.length, + imageCount: p.images.length, + createdAt: p.createdAt, + })); + return csvResponse(rows, `homaroma_catalog_${dt}.csv`); + } + + return jsonResponse( + { + exportedAt: new Date().toISOString(), + summary: { + products: products.length, + categories: categories.length, + attributes: attributes.length, + brands: brands.length, + }, + data: { products, categories, attributes, brands }, + }, + `homaroma_catalog_all_${dt}.json` + ); + } + + if (section === "categories") { + const categories = await prisma.category.findMany({ + where: { deletedAt: null }, + include: { translations: true }, + orderBy: { id: "asc" }, + }); + if (format === "csv") { + const rows = categories.map((c: any) => ({ + id: c.id, + name: c.name, + imageUrl: c.imageUrl, + createdAt: c.createdAt, + })); + return csvResponse(rows, `homaroma_categories_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: categories.length, data: categories }, `homaroma_categories_${dt}.json`); + } + + if (section === "attributes") { + const attributes = await prisma.attributeDefinition.findMany({ + include: { translations: true }, + orderBy: { sortOrder: "asc" }, + }); + if (format === "csv") { + const rows = attributes.map((a: any) => ({ + id: a.id, + code: a.code, + label: a.label, + type: a.type, + unit: a.unit, + isRequired: a.isRequired, + isFilterable: a.isFilterable, + sortOrder: a.sortOrder, + })); + return csvResponse(rows, `homaroma_attributes_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: attributes.length, data: attributes }, `homaroma_attributes_${dt}.json`); + } + + if (section === "brands") { + const brands = await prisma.productBrand.findMany({ + include: { translations: true }, + orderBy: { id: "asc" }, + }); + if (format === "csv") { + const rows = brands.map((b: any) => ({ id: b.id, name: b.name, createdAt: b.createdAt })); + return csvResponse(rows, `homaroma_brands_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: brands.length, data: brands }, `homaroma_brands_${dt}.json`); + } + + set.status = 400; + return { error: `Unknown section: ${section}. Valid values: all, products, categories, attributes, brands` }; + } catch (err: any) { + console.error("[Backup] Catalog export error:", err); + set.status = 500; + return { error: "Catalog export failed", detail: err?.message }; + } + }) + + // ── 2. Orders & Customers Export ────────────────────────────────────────── + /** + * GET /api/backup/orders?section=all|orders|customers|coupons|promotions&format=json|csv&from=YYYY-MM-DD&to=YYYY-MM-DD + */ + .get("/orders", async ({ query, set }: { query: any; set: any }) => { + try { + const section = (query.section as string) || "all"; + const format = (query.format as string) === "csv" ? "csv" : "json"; + const from = query.from as string | undefined; + const to = query.to as string | undefined; + const dt = today(); + + const dateFilter = + from || to + ? { + createdAt: { + ...(from ? { gte: new Date(`${from}T00:00:00.000Z`) } : {}), + ...(to ? { lte: new Date(`${to}T23:59:59.999Z`) } : {}), + }, + } + : {}; + + if (section === "orders" || section === "all") { + const orders = await prisma.order.findMany({ + where: dateFilter, + include: { items: true, customer: { select: { id: true, displayName: true, lineUserId: true } } }, + orderBy: { createdAt: "desc" }, + }); + + if (section === "orders") { + if (format === "csv") { + const rows = orders.map((o: any) => ({ + id: o.id, + customerName: o.customerName, + customerEmail: o.customerEmail, + customerPhone: o.customerPhone, + totalAmount: o.totalAmount, + status: o.status, + trackingNo: o.trackingNo, + createdAt: o.createdAt, + })); + return csvResponse(rows, `homaroma_orders_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: orders.length, data: orders }, `homaroma_orders_${dt}.json`); + } + + // full + const customers = await prisma.customer.findMany({ + include: { addresses: true }, + orderBy: { id: "asc" }, + }); + const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } }); + const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } }); + + if (format === "csv") { + const rows = orders.map((o: any) => ({ + id: o.id, + customerName: o.customerName, + customerEmail: o.customerEmail, + totalAmount: o.totalAmount, + status: o.status, + createdAt: o.createdAt, + })); + return csvResponse(rows, `homaroma_orders_all_${dt}.csv`); + } + + return jsonResponse( + { + exportedAt: new Date().toISOString(), + dateRange: from || to ? { from: from ?? "all", to: to ?? "all" } : "all", + summary: { + orders: orders.length, + customers: customers.length, + coupons: coupons.length, + promotions: promotions.length, + }, + data: { orders, customers, coupons, promotions }, + }, + `homaroma_orders_full_${dt}.json` + ); + } + + if (section === "customers") { + const customers = await prisma.customer.findMany({ + include: { addresses: true }, + orderBy: { id: "asc" }, + }); + if (format === "csv") { + const rows = customers.map((c: any) => ({ + id: c.id, + displayName: c.displayName, + email: c.email, + phone: c.phone, + points: c.points, + createdAt: c.createdAt, + })); + return csvResponse(rows, `homaroma_customers_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: customers.length, data: customers }, `homaroma_customers_${dt}.json`); + } + + if (section === "coupons") { + const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } }); + if (format === "csv") { + const rows = coupons.map((c: any) => ({ + id: c.id, + code: c.code, + type: c.type, + value: c.value, + usageLimit: c.usageLimit, + usedCount: c.usedCount, + isActive: c.isActive, + expiresAt: c.expiresAt, + })); + return csvResponse(rows, `homaroma_coupons_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: coupons.length, data: coupons }, `homaroma_coupons_${dt}.json`); + } + + if (section === "promotions") { + const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } }); + if (format === "csv") { + const rows = promotions.map((p: any) => ({ + id: p.id, + name: p.name, + level: p.level, + type: p.type, + value: p.value, + minPurchase: p.minPurchase, + isActive: p.isActive, + })); + return csvResponse(rows, `homaroma_promotions_${dt}.csv`); + } + return jsonResponse({ exportedAt: new Date().toISOString(), count: promotions.length, data: promotions }, `homaroma_promotions_${dt}.json`); + } + + set.status = 400; + return { error: `Unknown section: ${section}. Valid values: all, orders, customers, coupons, promotions` }; + } catch (err: any) { + console.error("[Backup] Orders export error:", err); + set.status = 500; + return { error: "Orders export failed", detail: err?.message }; + } + }) + + // ── 3. CMS & Content Export ──────────────────────────────────────────────── + /** + * GET /api/backup/cms?section=all|pages|menus|articles|settings + * Always returns JSON (CMS data is structured — CSV not useful here). + */ + .get("/cms", async ({ query, set }: { query: any; set: any }) => { + try { + const section = (query.section as string) || "all"; + const dt = today(); + + if (section === "pages" || section === "all") { + const pages = await prisma.page.findMany({ + include: { sections: true, translations: true }, + orderBy: { id: "asc" }, + }); + + if (section === "pages") { + return jsonResponse({ exportedAt: new Date().toISOString(), count: pages.length, data: pages }, `homaroma_cms_pages_${dt}.json`); + } + + // fetch rest for full export + const menus = await prisma.menu.findMany({ + include: { items: { include: { translations: true } } }, + orderBy: { id: "asc" }, + }); + const articles = await prisma.article.findMany({ + include: { translations: true }, + orderBy: { createdAt: "desc" }, + }); + const settings = await prisma.storeSettings.findFirst({ + include: { translations: true }, + }); + const footerMenus = await prisma.footerMenu.findMany({ + include: { items: true }, + orderBy: { id: "asc" }, + }).catch(() => []); // graceful if table not present + + return jsonResponse( + { + exportedAt: new Date().toISOString(), + summary: { + pages: pages.length, + menus: menus.length, + articles: articles.length, + hasSettings: !!settings, + }, + data: { pages, menus, footerMenus, articles, settings }, + }, + `homaroma_cms_all_${dt}.json` + ); + } + + if (section === "menus") { + const menus = await prisma.menu.findMany({ + include: { items: { include: { translations: true } } }, + orderBy: { id: "asc" }, + }); + const footerMenus = await prisma.footerMenu.findMany({ + include: { items: true }, + orderBy: { id: "asc" }, + }).catch(() => []); + return jsonResponse( + { exportedAt: new Date().toISOString(), data: { menus, footerMenus } }, + `homaroma_cms_menus_${dt}.json` + ); + } + + if (section === "articles") { + const articles = await prisma.article.findMany({ + include: { translations: true }, + orderBy: { createdAt: "desc" }, + }); + return jsonResponse({ exportedAt: new Date().toISOString(), count: articles.length, data: articles }, `homaroma_cms_articles_${dt}.json`); + } + + if (section === "settings") { + const settings = await prisma.storeSettings.findFirst({ include: { translations: true } }); + return jsonResponse({ exportedAt: new Date().toISOString(), data: settings }, `homaroma_cms_settings_${dt}.json`); + } + + set.status = 400; + return { error: `Unknown section: ${section}. Valid values: all, pages, menus, articles, settings` }; + } catch (err: any) { + console.error("[Backup] CMS export error:", err); + set.status = 500; + return { error: "CMS export failed", detail: err?.message }; + } + }) + + // ── 4. Media Files — list ────────────────────────────────────────────────── + /** + * GET /api/backup/media + * Returns JSON list of all media file records + filesystem info. + */ + .get("/media", async ({ set }: { set: any }) => { + try { + const media = await prisma.media.findMany({ orderBy: { createdAt: "desc" } }); + const uploadsDir = path.resolve("public/uploads"); + + let fsFiles: string[] = []; + try { + fsFiles = fs.readdirSync(uploadsDir); + } catch { + // uploads dir may not exist yet + } + + return { + exportedAt: new Date().toISOString(), + dbRecords: media.length, + fsFiles: fsFiles.length, + uploadsPath: uploadsDir, + media, + }; + } catch (err: any) { + console.error("[Backup] Media list error:", err); + set.status = 500; + return { error: "Media list failed", detail: err?.message }; + } + }) + + // ── 5. Media Files — zip download ───────────────────────────────────────── + /** + * GET /api/backup/media/zip + * Zips the public/uploads/ directory and streams the .zip file. + * Uses Bun's native spawn to call system zip command. + */ + .get("/media/zip", async ({ set }: { set: any }) => { + try { + const uploadsDir = path.resolve("public/uploads"); + if (!fs.existsSync(uploadsDir)) { + set.status = 404; + return { error: "uploads directory not found", path: uploadsDir }; + } + + const dt = today(); + const tmpZip = path.join("/tmp", `homaroma_media_${dt}_${Date.now()}.zip`); + + // Use Bun.spawn to call system zip + const proc = Bun.spawn(["zip", "-r", tmpZip, "."], { + cwd: uploadsDir, + stderr: "pipe", + }); + await proc.exited; + + if (!fs.existsSync(tmpZip)) { + set.status = 500; + return { error: "zip creation failed — ensure `zip` is installed on the server" }; + } + + const fileData = await Bun.file(tmpZip).arrayBuffer(); + + // Clean up temp file + try { fs.unlinkSync(tmpZip); } catch { /* ignore */ } + + return new Response(fileData, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="homaroma_media_${dt}.zip"`, + "Content-Length": String(fileData.byteLength), + }, + }); + } catch (err: any) { + console.error("[Backup] Media zip error:", err); + set.status = 500; + return { error: "Media zip failed", detail: err?.message }; + } + }) + + // ── 6. Database info ────────────────────────────────────────────────────── + /** + * GET /api/backup/database + * Returns table row counts for dashboard display. + * Full pg_dump requires shell access — provide instructions. + */ + .get("/database", async ({ set }: { set: any }) => { + try { + const [ + products, categories, customers, orders, + articles, pages, menus, coupons, promotions, + media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders, + ] = await Promise.all([ + prisma.product.count(), + prisma.category.count({ where: { deletedAt: null } }), + prisma.customer.count(), + prisma.order.count(), + prisma.article.count(), + prisma.page.count(), + prisma.menu.count(), + prisma.coupon.count(), + prisma.promotion.count(), + prisma.media.count(), + prisma.newsletterSubscription.count().catch(() => 0), + prisma.rawMaterial.count(), + prisma.tiktokOrder.count(), + prisma.shopeeOrder.count(), + prisma.lazadaOrder.count(), + ]); + + return { + scannedAt: new Date().toISOString(), + tableCounts: { + products, + categories, + customers, + orders, + articles, + pages, + menus, + coupons, + promotions, + media, + subscribers, + rawMaterials, + tiktokOrders, + shopeeOrders, + lazadaOrders, + }, + pgDumpNote: "Full pg_dump requires shell access. Use SSH: pg_dump -U postgres > backup.sql", + }; + } catch (err: any) { + console.error("[Backup] Database info error:", err); + set.status = 500; + return { error: "Database info failed", detail: err?.message }; + } + });