x
This commit is contained in:
1 parent
5b81cc810a
commit
158fa1ac22
7 files changed
+1712
-319
No files matched your search
@@ -3,6 +3,7 @@ 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 ────────────────────────────────────
|
||||
|
||||
@@ -587,4 +588,637 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
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 };
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user