1225 lines
42 KiB
TypeScript
1225 lines
42 KiB
TypeScript
import { Elysia } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
import { adminOnly } from "../middleware/auth";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
|
|
// ─── 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 };
|
|
}
|
|
})
|
|
|
|
// ── 7. Restore CMS Content ────────────────────────────────────────────────
|
|
/**
|
|
* POST /api/backup/restore/cms
|
|
* Restores Pages, Sections, Navigation Menus, Articles, and Store Settings from JSON.
|
|
*/
|
|
.post("/restore/cms", async ({ body, set }: { body: any; set: any }) => {
|
|
try {
|
|
const payload = body?.data || body;
|
|
if (!payload || typeof payload !== "object") {
|
|
set.status = 400;
|
|
return { error: "Invalid CMS payload: Expected object with pages, menus, articles, or settings" };
|
|
}
|
|
|
|
const summary = {
|
|
pages: 0,
|
|
sections: 0,
|
|
menus: 0,
|
|
menuItems: 0,
|
|
articles: 0,
|
|
settings: false,
|
|
};
|
|
|
|
// 1. Restore Store Settings
|
|
const settings = payload.settings || (payload.brandName ? payload : null);
|
|
if (settings) {
|
|
const { id, translations, updatedAt, ...settingFields } = settings;
|
|
await prisma.storeSettings.upsert({
|
|
where: { id: 1 },
|
|
update: {
|
|
...settingFields,
|
|
updatedAt: new Date(),
|
|
},
|
|
create: {
|
|
id: 1,
|
|
...settingFields,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(translations)) {
|
|
for (const t of translations) {
|
|
if (t.langCode) {
|
|
await prisma.storeSettingsTranslation.upsert({
|
|
where: {
|
|
storeSettingsId_langCode: {
|
|
storeSettingsId: 1,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: {
|
|
footerDescription: t.footerDescription,
|
|
newsletterTitle: t.newsletterTitle,
|
|
newsletterDescription: t.newsletterDescription,
|
|
newsletterPlaceholder: t.newsletterPlaceholder,
|
|
newsletterButtonText: t.newsletterButtonText,
|
|
},
|
|
create: {
|
|
storeSettingsId: 1,
|
|
langCode: t.langCode,
|
|
footerDescription: t.footerDescription,
|
|
newsletterTitle: t.newsletterTitle,
|
|
newsletterDescription: t.newsletterDescription,
|
|
newsletterPlaceholder: t.newsletterPlaceholder,
|
|
newsletterButtonText: t.newsletterButtonText,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.settings = true;
|
|
}
|
|
|
|
// 2. Restore Articles
|
|
const articles = Array.isArray(payload.articles)
|
|
? payload.articles
|
|
: (Array.isArray(payload) && payload[0]?.slug && payload[0]?.content ? payload : []);
|
|
for (const art of articles) {
|
|
if (!art.slug || !art.title) continue;
|
|
const { id, translations, createdAt, updatedAt, ...artFields } = art;
|
|
const savedArticle = await prisma.article.upsert({
|
|
where: { slug: art.slug },
|
|
update: {
|
|
...artFields,
|
|
updatedAt: new Date(),
|
|
},
|
|
create: {
|
|
...artFields,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(translations)) {
|
|
for (const t of translations) {
|
|
if (t.langCode) {
|
|
await prisma.articleTranslation.upsert({
|
|
where: {
|
|
articleId_langCode: {
|
|
articleId: savedArticle.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: {
|
|
title: t.title,
|
|
content: t.content,
|
|
},
|
|
create: {
|
|
articleId: savedArticle.id,
|
|
langCode: t.langCode,
|
|
title: t.title,
|
|
content: t.content,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.articles++;
|
|
}
|
|
|
|
// 3. Restore Menus
|
|
const menus = Array.isArray(payload.menus) ? payload.menus : [];
|
|
for (const menu of menus) {
|
|
if (!menu.name) continue;
|
|
const savedMenu = await prisma.menu.upsert({
|
|
where: { name: menu.name },
|
|
update: {
|
|
payload: menu.payload,
|
|
rootImage: menu.rootImage,
|
|
isActive: menu.isActive ?? true,
|
|
},
|
|
create: {
|
|
name: menu.name,
|
|
payload: menu.payload,
|
|
rootImage: menu.rootImage,
|
|
isActive: menu.isActive ?? true,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(menu.items)) {
|
|
await prisma.menuItem.deleteMany({ where: { menuId: savedMenu.id } });
|
|
for (const item of menu.items) {
|
|
const createdItem = await prisma.menuItem.create({
|
|
data: {
|
|
menuId: savedMenu.id,
|
|
label: item.label,
|
|
url: item.url,
|
|
order: item.order ?? 0,
|
|
columnImage: item.columnImage,
|
|
hoverImage: item.hoverImage,
|
|
},
|
|
});
|
|
if (Array.isArray(item.translations)) {
|
|
for (const t of item.translations) {
|
|
if (t.langCode) {
|
|
await prisma.menuItemTranslation.create({
|
|
data: {
|
|
menuItemId: createdItem.id,
|
|
langCode: t.langCode,
|
|
label: t.label,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.menuItems++;
|
|
}
|
|
}
|
|
summary.menus++;
|
|
}
|
|
|
|
// 4. Restore Pages & Sections
|
|
const pages = Array.isArray(payload.pages)
|
|
? payload.pages
|
|
: (Array.isArray(payload) && payload[0]?.slug && (payload[0]?.sections || payload[0]?.title) ? payload : []);
|
|
for (const pg of pages) {
|
|
if (!pg.slug || !pg.title) continue;
|
|
const savedPage = await prisma.page.upsert({
|
|
where: { slug: pg.slug },
|
|
update: {
|
|
title: pg.title,
|
|
isPublished: pg.isPublished ?? true,
|
|
},
|
|
create: {
|
|
slug: pg.slug,
|
|
title: pg.title,
|
|
isPublished: pg.isPublished ?? true,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(pg.translations)) {
|
|
for (const t of pg.translations) {
|
|
if (t.langCode) {
|
|
await prisma.pageTranslation.upsert({
|
|
where: {
|
|
pageId_langCode: {
|
|
pageId: savedPage.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: { title: t.title },
|
|
create: { pageId: savedPage.id, langCode: t.langCode, title: t.title },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(pg.sections)) {
|
|
await prisma.pageSection.deleteMany({ where: { pageId: savedPage.id } });
|
|
for (const sec of pg.sections) {
|
|
await prisma.pageSection.create({
|
|
data: {
|
|
pageId: savedPage.id,
|
|
type: sec.type,
|
|
order: sec.order ?? 0,
|
|
payload: sec.payload ?? {},
|
|
isActive: sec.isActive ?? true,
|
|
},
|
|
});
|
|
summary.sections++;
|
|
}
|
|
}
|
|
summary.pages++;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: "CMS restored successfully",
|
|
summary,
|
|
};
|
|
} catch (err: any) {
|
|
console.error("[Backup] CMS restore error:", err);
|
|
set.status = 500;
|
|
return { error: "CMS restore failed", detail: err?.message };
|
|
}
|
|
})
|
|
|
|
// ── 8. Restore Catalog ────────────────────────────────────────────────────
|
|
/**
|
|
* POST /api/backup/restore/catalog
|
|
* Restores Categories, AttributeDefinitions, Brands, and Products from JSON.
|
|
*/
|
|
.post("/restore/catalog", async ({ body, set }: { body: any; set: any }) => {
|
|
try {
|
|
const payload = body?.data || body;
|
|
if (!payload || typeof payload !== "object") {
|
|
set.status = 400;
|
|
return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" };
|
|
}
|
|
|
|
const summary = {
|
|
categories: 0,
|
|
attributes: 0,
|
|
brands: 0,
|
|
products: 0,
|
|
variants: 0,
|
|
};
|
|
|
|
// 1. Categories
|
|
const categories = Array.isArray(payload.categories) ? payload.categories : [];
|
|
const categoryMap = new Map<number, number>();
|
|
|
|
for (const cat of categories) {
|
|
if (!cat.name) continue;
|
|
const savedCat = await prisma.category.upsert({
|
|
where: { name: cat.name },
|
|
update: {
|
|
imageUrl: cat.imageUrl,
|
|
deletedAt: null,
|
|
},
|
|
create: {
|
|
name: cat.name,
|
|
imageUrl: cat.imageUrl,
|
|
},
|
|
});
|
|
if (cat.id) categoryMap.set(cat.id, savedCat.id);
|
|
|
|
if (Array.isArray(cat.translations)) {
|
|
for (const t of cat.translations) {
|
|
if (t.langCode && t.name) {
|
|
await prisma.categoryTranslation.upsert({
|
|
where: {
|
|
categoryId_langCode: {
|
|
categoryId: savedCat.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: { name: t.name },
|
|
create: { categoryId: savedCat.id, langCode: t.langCode, name: t.name },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.categories++;
|
|
}
|
|
|
|
// 2. Brands
|
|
const brands = Array.isArray(payload.brands) ? payload.brands : [];
|
|
const brandMap = new Map<number, number>();
|
|
|
|
for (const b of brands) {
|
|
if (!b.name) continue;
|
|
const savedBrand = await prisma.productBrand.upsert({
|
|
where: { name: b.name },
|
|
update: {},
|
|
create: { name: b.name },
|
|
});
|
|
if (b.id) brandMap.set(b.id, savedBrand.id);
|
|
|
|
if (Array.isArray(b.translations)) {
|
|
for (const t of b.translations) {
|
|
if (t.langCode && t.name) {
|
|
await prisma.productBrandTranslation.upsert({
|
|
where: {
|
|
brandId_langCode: {
|
|
brandId: savedBrand.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: { name: t.name },
|
|
create: { brandId: savedBrand.id, langCode: t.langCode, name: t.name },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.brands++;
|
|
}
|
|
|
|
// Fallback category and brand
|
|
let defaultCatId = categoryMap.values().next().value;
|
|
if (!defaultCatId) {
|
|
const firstCat = await prisma.category.findFirst({ where: { deletedAt: null } });
|
|
if (firstCat) {
|
|
defaultCatId = firstCat.id;
|
|
} else {
|
|
const newCat = await prisma.category.create({ data: { name: "General" } });
|
|
defaultCatId = newCat.id;
|
|
}
|
|
}
|
|
|
|
let defaultBrandId = brandMap.values().next().value;
|
|
if (!defaultBrandId) {
|
|
const firstBrand = await prisma.productBrand.findFirst();
|
|
if (firstBrand) {
|
|
defaultBrandId = firstBrand.id;
|
|
} else {
|
|
const newBrand = await prisma.productBrand.create({ data: { name: "HomAroma" } });
|
|
defaultBrandId = newBrand.id;
|
|
}
|
|
}
|
|
|
|
// 3. Attribute Definitions
|
|
const attributes = Array.isArray(payload.attributes) ? payload.attributes : [];
|
|
for (const attr of attributes) {
|
|
if (!attr.code || !attr.label) continue;
|
|
const mappedCatId = attr.categoryId ? (categoryMap.get(attr.categoryId) || defaultCatId) : null;
|
|
const savedAttr = await prisma.attributeDefinition.upsert({
|
|
where: { code: attr.code },
|
|
update: {
|
|
label: attr.label,
|
|
description: attr.description,
|
|
type: attr.type || "TEXT",
|
|
unit: attr.unit,
|
|
options: attr.options,
|
|
isRequired: attr.isRequired ?? false,
|
|
isFilterable: attr.isFilterable ?? false,
|
|
sortOrder: attr.sortOrder ?? 0,
|
|
categoryId: mappedCatId,
|
|
},
|
|
create: {
|
|
code: attr.code,
|
|
label: attr.label,
|
|
description: attr.description,
|
|
type: attr.type || "TEXT",
|
|
unit: attr.unit,
|
|
options: attr.options,
|
|
isRequired: attr.isRequired ?? false,
|
|
isFilterable: attr.isFilterable ?? false,
|
|
sortOrder: attr.sortOrder ?? 0,
|
|
categoryId: mappedCatId,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(attr.translations)) {
|
|
for (const t of attr.translations) {
|
|
if (t.langCode && t.label) {
|
|
await prisma.attributeDefinitionTranslation.upsert({
|
|
where: {
|
|
attributeDefinitionId_langCode: {
|
|
attributeDefinitionId: savedAttr.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: {
|
|
label: t.label,
|
|
description: t.description,
|
|
},
|
|
create: {
|
|
attributeDefinitionId: savedAttr.id,
|
|
langCode: t.langCode,
|
|
label: t.label,
|
|
description: t.description,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
summary.attributes++;
|
|
}
|
|
|
|
// 4. Products
|
|
const products = Array.isArray(payload.products)
|
|
? payload.products
|
|
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
|
|
|
|
for (const prod of products) {
|
|
if (!prod.name) continue;
|
|
const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId;
|
|
const bId = prod.brandId ? (brandMap.get(prod.brandId) || prod.brandId) : defaultBrandId;
|
|
|
|
let existing = prod.id ? await prisma.product.findUnique({ where: { id: prod.id } }) : null;
|
|
if (!existing && prod.name) {
|
|
existing = await prisma.product.findFirst({ where: { name: prod.name } });
|
|
}
|
|
|
|
let savedProduct;
|
|
if (existing) {
|
|
savedProduct = await prisma.product.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
name: prod.name,
|
|
description: prod.description,
|
|
details: prod.details,
|
|
categoryId: catId,
|
|
brandId: bId,
|
|
options: prod.options ?? [],
|
|
},
|
|
});
|
|
} else {
|
|
savedProduct = await prisma.product.create({
|
|
data: {
|
|
name: prod.name,
|
|
description: prod.description,
|
|
details: prod.details,
|
|
categoryId: catId,
|
|
brandId: bId,
|
|
options: prod.options ?? [],
|
|
},
|
|
});
|
|
}
|
|
|
|
// Variants
|
|
if (Array.isArray(prod.variants)) {
|
|
for (const v of prod.variants) {
|
|
const opt1 = v.option1 ?? "";
|
|
const opt2 = v.option2 ?? "";
|
|
const opt3 = v.option3 ?? "";
|
|
|
|
await prisma.productVariant.upsert({
|
|
where: {
|
|
productId_option1_option2_option3: {
|
|
productId: savedProduct.id,
|
|
option1: opt1,
|
|
option2: opt2,
|
|
option3: opt3,
|
|
},
|
|
},
|
|
update: {
|
|
sku: v.sku || undefined,
|
|
barcode: v.barcode,
|
|
price: Number(v.price) || 0,
|
|
stock: Number(v.stock) || 0,
|
|
},
|
|
create: {
|
|
productId: savedProduct.id,
|
|
option1: opt1,
|
|
option2: opt2,
|
|
option3: opt3,
|
|
sku: v.sku || undefined,
|
|
barcode: v.barcode,
|
|
price: Number(v.price) || 0,
|
|
stock: Number(v.stock) || 0,
|
|
},
|
|
});
|
|
summary.variants++;
|
|
}
|
|
}
|
|
|
|
// Images
|
|
if (Array.isArray(prod.images) && prod.images.length > 0) {
|
|
await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } });
|
|
for (let i = 0; i < prod.images.length; i++) {
|
|
const img = prod.images[i];
|
|
const url = typeof img === "string" ? img : img.url;
|
|
if (url) {
|
|
await prisma.productImage.create({
|
|
data: {
|
|
productId: savedProduct.id,
|
|
url,
|
|
order: typeof img === "object" && img.order !== undefined ? img.order : i,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Translations
|
|
if (Array.isArray(prod.translations)) {
|
|
for (const t of prod.translations) {
|
|
if (t.langCode && t.name) {
|
|
await prisma.productTranslation.upsert({
|
|
where: {
|
|
productId_langCode: {
|
|
productId: savedProduct.id,
|
|
langCode: t.langCode,
|
|
},
|
|
},
|
|
update: {
|
|
name: t.name,
|
|
description: t.description,
|
|
details: t.details,
|
|
},
|
|
create: {
|
|
productId: savedProduct.id,
|
|
langCode: t.langCode,
|
|
name: t.name,
|
|
description: t.description,
|
|
details: t.details,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
summary.products++;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: "Catalog restored successfully",
|
|
summary,
|
|
};
|
|
} catch (err: any) {
|
|
console.error("[Backup] Catalog restore error:", err);
|
|
set.status = 500;
|
|
return { error: "Catalog restore failed", detail: err?.message };
|
|
}
|
|
})
|
|
|
|
// ── 9. Restore Media Files (.zip) ─────────────────────────────────────────
|
|
/**
|
|
* POST /api/backup/restore/media
|
|
* Accepts a multipart ZIP file, extracts images to /uploads, and registers Media DB records.
|
|
*/
|
|
.post("/restore/media", async ({ body, set }: { body: any; set: any }) => {
|
|
try {
|
|
const { file } = body as { file?: File };
|
|
if (!file) {
|
|
set.status = 400;
|
|
return { error: "No zip file uploaded" };
|
|
}
|
|
|
|
const uploadsDir = path.resolve("public/uploads");
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
const tmpZip = path.join(os.tmpdir(), `homaroma_restore_${Date.now()}.zip`);
|
|
await Bun.write(tmpZip, await file.arrayBuffer());
|
|
|
|
// Extract ZIP safely
|
|
const isWindows = process.platform === "win32";
|
|
if (isWindows) {
|
|
const proc = Bun.spawn([
|
|
"powershell",
|
|
"-NoProfile",
|
|
"-Command",
|
|
`Expand-Archive -LiteralPath '${tmpZip}' -DestinationPath '${uploadsDir}' -Force`,
|
|
]);
|
|
await proc.exited;
|
|
} else {
|
|
const proc = Bun.spawn(["unzip", "-o", tmpZip, "-d", uploadsDir]);
|
|
await proc.exited;
|
|
}
|
|
|
|
// Cleanup tmp zip
|
|
try { fs.unlinkSync(tmpZip); } catch { /* ignore */ }
|
|
|
|
// Scan extracted files and sync with Prisma Media records
|
|
const files = fs.readdirSync(uploadsDir);
|
|
let syncCount = 0;
|
|
|
|
for (const filename of files) {
|
|
const filePath = path.join(uploadsDir, filename);
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.isFile()) {
|
|
const relativePath = `/uploads/${filename}`;
|
|
const ext = path.extname(filename).toLowerCase().replace(".", "");
|
|
const mimeType =
|
|
ext === "png"
|
|
? "image/png"
|
|
: ext === "jpg" || ext === "jpeg"
|
|
? "image/jpeg"
|
|
: ext === "webp"
|
|
? "image/webp"
|
|
: ext === "mp4"
|
|
? "video/mp4"
|
|
: "application/octet-stream";
|
|
|
|
const existingMedia = await prisma.media.findFirst({ where: { filename } });
|
|
if (!existingMedia) {
|
|
await prisma.media.create({
|
|
data: {
|
|
filename,
|
|
path: relativePath,
|
|
mimeType,
|
|
sizeBytes: stat.size,
|
|
},
|
|
});
|
|
syncCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Media files restored successfully. Total ${files.length} files in /uploads (${syncCount} newly indexed).`,
|
|
totalFiles: files.length,
|
|
syncedRecords: syncCount,
|
|
};
|
|
} catch (err: any) {
|
|
console.error("[Backup] Media restore error:", err);
|
|
set.status = 500;
|
|
return { error: "Media restore failed", detail: err?.message };
|
|
}
|
|
});
|