This commit is contained in:
Your Name committed 2026-08-21 23:07:44 +00:00
1 parent 2ac5809895
commit 3afe32db7d
10 files changed
+1782 -1

No files matched your search

+590
View File
@@ -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<string, unknown>[], 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 <dbname> > backup.sql",
};
} catch (err: any) {
console.error("[Backup] Database info error:", err);
set.status = 500;
return { error: "Database info failed", detail: err?.message };
}
});